From df7b16d5b37f0e2b986b582854cf81425562232e Mon Sep 17 00:00:00 2001 From: Andrew <11490628+andrew000@users.noreply.github.com> Date: Mon, 6 Oct 2025 19:19:23 +0300 Subject: [PATCH] EOL of Py3.9 (#1726) * Drop py3.9 and pypy3.9 Add pypy3.11 (testing) into `tests.yml` Remove py3.9 from matrix in `tests.yml` Refactor not auto-gen code to be compatible with py3.10+, droping ugly 3.9 annotation. Replace some `from typing` imports to `from collections.abc`, due to deprecation Add `from __future__ import annotations` and `if TYPE_CHECKING:` where possible Add some `noqa` to calm down Ruff in some places, if Ruff will be used as default linting+formatting tool in future Replace some relative imports to absolute Sort `__all__` tuples in `__init__.py` and some other `.py` files Sort `__slots__` tuples in classes Split raises into `msg` and `raise` (`EM101`, `EM102`) to not duplicate error message in the traceback Add `Self` from `typing_extenstion` where possible Resolve typing problem in `aiogram/filters/command.py:18` Concatenate nested `if` statements Convert `HandlerContainer` into a dataclass in `aiogram/fsm/scene.py` Bump tests docker-compose.yml `redis:6-alpine` -> `redis:8-alpine` Bump tests docker-compose.yml `mongo:7.0.6` -> `mongo:8.0.14` Bump pre-commit-config `black==24.4.2` -> `black==25.9.0` Bump pre-commit-config `ruff==0.5.1` -> `ruff==0.13.3` Update Makefile lint for ruff to show fixes Add `make outdated` into Makefile Use `pathlib` instead of `os.path` Bump `redis[hiredis]>=5.0.1,<5.3.0` -> `redis[hiredis]>=6.2.0,<7` Bump `cryptography>=43.0.0` -> `cryptography>=46.0.0` due to security reasons Bump `pytz~=2023.3` -> `pytz~=2025.2` Bump `pycryptodomex~=3.19.0` -> `pycryptodomex~=3.23.0` due to security reasons Bump linting and formatting tools * Add `1726.removal.rst` * Update aiogram/utils/dataclass.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update aiogram/filters/callback_data.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update 1726.removal.rst * Remove `outdated` from Makefile * Add `__slots__` to `HandlerContainer` * Remove unused imports * Add `@dataclass` with `slots=True` to `HandlerContainer` --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/workflows/pull_request_changelog.yml | 2 +- .github/workflows/tests.yml | 3 +- .pre-commit-config.yaml | 4 +- CHANGES/1726.removal.rst | 7 + Makefile | 2 +- README.rst | 2 +- aiogram/__init__.py | 18 +- aiogram/client/context_controller.py | 2 +- aiogram/client/default.py | 26 +-- aiogram/client/session/aiohttp.py | 84 +++++---- aiogram/client/session/base.py | 77 +++++---- aiogram/client/session/middlewares/base.py | 11 +- aiogram/client/session/middlewares/manager.py | 17 +- .../session/middlewares/request_logging.py | 8 +- aiogram/client/telegram.py | 25 +-- aiogram/dispatcher/dispatcher.py | 151 +++++++++------- aiogram/dispatcher/event/bases.py | 24 +-- aiogram/dispatcher/event/event.py | 5 +- aiogram/dispatcher/event/handler.py | 20 +-- aiogram/dispatcher/event/telegram.py | 29 ++-- aiogram/dispatcher/flags.py | 23 +-- aiogram/dispatcher/middlewares/base.py | 8 +- aiogram/dispatcher/middlewares/error.py | 16 +- aiogram/dispatcher/middlewares/manager.py | 25 +-- .../dispatcher/middlewares/user_context.py | 28 +-- aiogram/dispatcher/router.py | 79 +++++---- aiogram/exceptions.py | 2 +- aiogram/filters/__init__.py | 32 ++-- aiogram/filters/base.py | 13 +- aiogram/filters/callback_data.py | 69 ++++---- aiogram/filters/chat_member_updated.py | 69 +++++--- aiogram/filters/command.py | 72 ++++---- aiogram/filters/exception.py | 14 +- aiogram/filters/logic.py | 8 +- aiogram/filters/magic_data.py | 2 +- aiogram/filters/state.py | 25 ++- aiogram/fsm/context.py | 17 +- aiogram/fsm/middleware.py | 25 +-- aiogram/fsm/scene.py | 163 +++++++++--------- aiogram/fsm/state.py | 55 +++--- aiogram/fsm/storage/base.py | 45 ++--- aiogram/fsm/storage/memory.py | 40 ++--- aiogram/fsm/storage/mongo.py | 25 +-- aiogram/fsm/storage/pymongo.py | 29 ++-- aiogram/fsm/storage/redis.py | 33 ++-- aiogram/fsm/strategy.py | 5 +- aiogram/handlers/base.py | 9 +- aiogram/handlers/callback_query.py | 4 +- aiogram/handlers/message.py | 4 +- aiogram/handlers/poll.py | 3 +- aiogram/utils/auth_widget.py | 8 +- aiogram/utils/backoff.py | 6 +- aiogram/utils/callback_answer.py | 59 ++++--- aiogram/utils/chat_action.py | 66 +++---- aiogram/utils/chat_member.py | 23 ++- aiogram/utils/class_attrs_resolver.py | 3 +- aiogram/utils/dataclass.py | 33 ++-- aiogram/utils/deep_linking.py | 30 ++-- aiogram/utils/formatting.py | 75 ++++---- aiogram/utils/i18n/__init__.py | 8 +- aiogram/utils/i18n/context.py | 3 +- aiogram/utils/i18n/core.py | 58 ++++--- aiogram/utils/i18n/lazy_proxy.py | 3 +- aiogram/utils/i18n/middleware.py | 49 +++--- aiogram/utils/keyboard.py | 140 ++++++++------- aiogram/utils/link.py | 6 +- aiogram/utils/magic_filter.py | 3 +- aiogram/utils/markdown.py | 4 +- aiogram/utils/media_group.py | 154 ++++++++--------- aiogram/utils/mixins.py | 51 +++--- aiogram/utils/mypy_hacks.py | 7 +- aiogram/utils/payload.py | 11 +- aiogram/utils/serialization.py | 12 +- aiogram/utils/text_decorations.py | 19 +- aiogram/utils/token.py | 12 +- aiogram/utils/web_app.py | 46 ++--- aiogram/utils/web_app_signature.py | 16 +- aiogram/webhook/aiohttp_server.py | 36 ++-- aiogram/webhook/security.py | 17 +- examples/context_addition_from_filter.py | 11 +- examples/error_handling.py | 9 +- examples/finite_state_machine.py | 10 +- examples/multibot.py | 8 +- examples/own_filter.py | 2 +- examples/quiz_scene.py | 6 +- examples/scene.py | 36 ++-- examples/specify_updates.py | 4 +- examples/web_app/handlers.py | 19 +- examples/web_app/main.py | 6 +- examples/web_app/routes.py | 25 ++- examples/without_dispatcher.py | 2 +- pyproject.toml | 35 ++-- tests/docker-compose.yml | 6 +- tests/test_utils/test_dataclass.py | 2 - 94 files changed, 1383 insertions(+), 1215 deletions(-) create mode 100644 CHANGES/1726.removal.rst diff --git a/.github/workflows/pull_request_changelog.yml b/.github/workflows/pull_request_changelog.yml index e91a898f..ecbfd2bc 100644 --- a/.github/workflows/pull_request_changelog.yml +++ b/.github/workflows/pull_request_changelog.yml @@ -19,7 +19,7 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} fetch-depth: '0' - - name: Set up Python 3.10 + - name: Set up Python 3.12 uses: actions/setup-python@v5 with: python-version: "3.12" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ee3a87ad..305b4701 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -29,7 +29,6 @@ jobs: - macos-latest - windows-latest python-version: - - "3.9" - "3.10" - "3.11" - "3.12" @@ -111,8 +110,8 @@ jobs: - macos-latest # - windows-latest python-version: - - "pypy3.9" - "pypy3.10" + - "pypy3.11" defaults: # Windows sucks. Force use bash instead of PowerShell diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d6537dce..6b91d3d1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,12 +14,12 @@ repos: - id: "check-json" - repo: https://github.com/psf/black - rev: 24.4.2 + rev: 25.9.0 hooks: - id: black files: &files '^(aiogram|tests|examples)' - repo: https://github.com/astral-sh/ruff-pre-commit - rev: 'v0.5.1' + rev: 'v0.13.3' hooks: - id: ruff diff --git a/CHANGES/1726.removal.rst b/CHANGES/1726.removal.rst new file mode 100644 index 00000000..dbd83691 --- /dev/null +++ b/CHANGES/1726.removal.rst @@ -0,0 +1,7 @@ +This PR updates the codebase following the end of life for Python 3.9. + +Reference: https://devguide.python.org/versions/ + +- Updated type annotations to Python 3.10+ style, replacing deprecated ``List``, ``Set``, etc., with built-in ``list``, ``set``, and related types. +- Refactored code by simplifying nested ``if`` expressions. +- Updated several dependencies, including security-related upgrades. diff --git a/Makefile b/Makefile index 14717df4..e30d71aa 100644 --- a/Makefile +++ b/Makefile @@ -39,7 +39,7 @@ install: clean lint: isort --check-only $(code_dir) black --check --diff $(code_dir) - ruff check $(package_dir) $(examples_dir) + ruff check --show-fixes --preview $(package_dir) $(examples_dir) mypy $(package_dir) .PHONY: reformat diff --git a/README.rst b/README.rst index 7e55cfc4..350a05f8 100644 --- a/README.rst +++ b/README.rst @@ -35,7 +35,7 @@ aiogram :alt: Codecov **aiogram** is a modern and fully asynchronous framework for -`Telegram Bot API `_ written in Python 3.8+ using +`Telegram Bot API `_ written in Python 3.10+ using `asyncio `_ and `aiohttp `_. diff --git a/aiogram/__init__.py b/aiogram/__init__.py index 1fbee0ec..b243ea2f 100644 --- a/aiogram/__init__.py +++ b/aiogram/__init__.py @@ -24,18 +24,18 @@ F = MagicFilter() flags = FlagGenerator() __all__ = ( + "BaseMiddleware", + "Bot", + "Dispatcher", + "F", + "Router", "__api_version__", "__version__", - "types", - "methods", "enums", - "Bot", - "session", - "Dispatcher", - "Router", - "BaseMiddleware", - "F", + "flags", "html", "md", - "flags", + "methods", + "session", + "types", ) diff --git a/aiogram/client/context_controller.py b/aiogram/client/context_controller.py index 97795a73..18788e49 100644 --- a/aiogram/client/context_controller.py +++ b/aiogram/client/context_controller.py @@ -10,7 +10,7 @@ if TYPE_CHECKING: class BotContextController(BaseModel): _bot: Optional["Bot"] = PrivateAttr() - def model_post_init(self, __context: Any) -> None: + def model_post_init(self, __context: Any) -> None: # noqa: PYI063 self._bot = __context.get("bot") if __context else None def as_(self, bot: Optional["Bot"]) -> Self: diff --git a/aiogram/client/default.py b/aiogram/client/default.py index 78dd0aa3..ee422982 100644 --- a/aiogram/client/default.py +++ b/aiogram/client/default.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Optional +from typing import TYPE_CHECKING, Any from aiogram.utils.dataclass import dataclass_kwargs @@ -35,25 +35,25 @@ class DefaultBotProperties: Default bot properties. """ - parse_mode: Optional[str] = None + parse_mode: str | None = None """Default parse mode for messages.""" - disable_notification: Optional[bool] = None + disable_notification: bool | None = None """Sends the message silently. Users will receive a notification with no sound.""" - protect_content: Optional[bool] = None + protect_content: bool | None = None """Protects content from copying.""" - allow_sending_without_reply: Optional[bool] = None + allow_sending_without_reply: bool | None = None """Allows to send messages without reply.""" - link_preview: Optional[LinkPreviewOptions] = None + link_preview: LinkPreviewOptions | None = None """Link preview settings.""" - link_preview_is_disabled: Optional[bool] = None + link_preview_is_disabled: bool | None = None """Disables link preview.""" - link_preview_prefer_small_media: Optional[bool] = None + link_preview_prefer_small_media: bool | None = None """Prefer small media in link preview.""" - link_preview_prefer_large_media: Optional[bool] = None + link_preview_prefer_large_media: bool | None = None """Prefer large media in link preview.""" - link_preview_show_above_text: Optional[bool] = None + link_preview_show_above_text: bool | None = None """Show link preview above text.""" - show_caption_above_media: Optional[bool] = None + show_caption_above_media: bool | None = None """Show caption above media.""" def __post_init__(self) -> None: @@ -63,11 +63,11 @@ class DefaultBotProperties: self.link_preview_prefer_small_media, self.link_preview_prefer_large_media, self.link_preview_show_above_text, - ) + ), ) if has_any_link_preview_option and self.link_preview is None: - from ..types import LinkPreviewOptions + from aiogram.types import LinkPreviewOptions self.link_preview = LinkPreviewOptions( is_disabled=self.link_preview_is_disabled, diff --git a/aiogram/client/session/aiohttp.py b/aiogram/client/session/aiohttp.py index b832af62..7b032c0e 100644 --- a/aiogram/client/session/aiohttp.py +++ b/aiogram/client/session/aiohttp.py @@ -2,45 +2,35 @@ from __future__ import annotations import asyncio import ssl -from typing import ( - TYPE_CHECKING, - Any, - AsyncGenerator, - Dict, - Iterable, - List, - Optional, - Tuple, - Type, - Union, - cast, -) +from collections.abc import AsyncGenerator, Iterable +from typing import TYPE_CHECKING, Any, cast import certifi from aiohttp import BasicAuth, ClientError, ClientSession, FormData, TCPConnector from aiohttp.hdrs import USER_AGENT from aiohttp.http import SERVER_SOFTWARE +from typing_extensions import Self from aiogram.__meta__ import __version__ -from aiogram.methods import TelegramMethod +from aiogram.exceptions import TelegramNetworkError +from aiogram.methods.base import TelegramType -from ...exceptions import TelegramNetworkError -from ...methods.base import TelegramType -from ...types import InputFile from .base import BaseSession if TYPE_CHECKING: - from ..bot import Bot + from aiogram.client.bot import Bot + from aiogram.methods import TelegramMethod + from aiogram.types import InputFile -_ProxyBasic = Union[str, Tuple[str, BasicAuth]] +_ProxyBasic = str | tuple[str, BasicAuth] _ProxyChain = Iterable[_ProxyBasic] -_ProxyType = Union[_ProxyChain, _ProxyBasic] +_ProxyType = _ProxyChain | _ProxyBasic -def _retrieve_basic(basic: _ProxyBasic) -> Dict[str, Any]: +def _retrieve_basic(basic: _ProxyBasic) -> dict[str, Any]: from aiohttp_socks.utils import parse_proxy_url - proxy_auth: Optional[BasicAuth] = None + proxy_auth: BasicAuth | None = None if isinstance(basic, str): proxy_url = basic @@ -62,7 +52,7 @@ def _retrieve_basic(basic: _ProxyBasic) -> Dict[str, Any]: } -def _prepare_connector(chain_or_plain: _ProxyType) -> Tuple[Type["TCPConnector"], Dict[str, Any]]: +def _prepare_connector(chain_or_plain: _ProxyType) -> tuple[type[TCPConnector], dict[str, Any]]: from aiohttp_socks import ChainProxyConnector, ProxyConnector, ProxyInfo # since tuple is Iterable(compatible with _ProxyChain) object, we assume that @@ -74,17 +64,13 @@ def _prepare_connector(chain_or_plain: _ProxyType) -> Tuple[Type["TCPConnector"] return ProxyConnector, _retrieve_basic(chain_or_plain) chain_or_plain = cast(_ProxyChain, chain_or_plain) - infos: List[ProxyInfo] = [] - for basic in chain_or_plain: - infos.append(ProxyInfo(**_retrieve_basic(basic))) + infos: list[ProxyInfo] = [ProxyInfo(**_retrieve_basic(basic)) for basic in chain_or_plain] return ChainProxyConnector, {"proxy_infos": infos} class AiohttpSession(BaseSession): - def __init__( - self, proxy: Optional[_ProxyType] = None, limit: int = 100, **kwargs: Any - ) -> None: + def __init__(self, proxy: _ProxyType | None = None, limit: int = 100, **kwargs: Any) -> None: """ Client session based on aiohttp. @@ -94,31 +80,32 @@ class AiohttpSession(BaseSession): """ super().__init__(**kwargs) - self._session: Optional[ClientSession] = None - self._connector_type: Type[TCPConnector] = TCPConnector - self._connector_init: Dict[str, Any] = { + self._session: ClientSession | None = None + self._connector_type: type[TCPConnector] = TCPConnector + self._connector_init: dict[str, Any] = { "ssl": ssl.create_default_context(cafile=certifi.where()), "limit": limit, "ttl_dns_cache": 3600, # Workaround for https://github.com/aiogram/aiogram/issues/1500 } self._should_reset_connector = True # flag determines connector state - self._proxy: Optional[_ProxyType] = None + self._proxy: _ProxyType | None = None if proxy is not None: try: self._setup_proxy_connector(proxy) except ImportError as exc: # pragma: no cover - raise RuntimeError( + msg = ( "In order to use aiohttp client for proxy requests, install " "https://pypi.org/project/aiohttp-socks/" - ) from exc + ) + raise RuntimeError(msg) from exc def _setup_proxy_connector(self, proxy: _ProxyType) -> None: self._connector_type, self._connector_init = _prepare_connector(proxy) self._proxy = proxy @property - def proxy(self) -> Optional[_ProxyType]: + def proxy(self) -> _ProxyType | None: return self._proxy @proxy.setter @@ -151,7 +138,7 @@ class AiohttpSession(BaseSession): def build_form_data(self, bot: Bot, method: TelegramMethod[TelegramType]) -> FormData: form = FormData(quote_fields=False) - files: Dict[str, InputFile] = {} + files: dict[str, InputFile] = {} for key, value in method.model_dump(warnings=False).items(): value = self.prepare_value(value, bot=bot, files=files) if not value: @@ -166,7 +153,10 @@ class AiohttpSession(BaseSession): return form async def make_request( - self, bot: Bot, method: TelegramMethod[TelegramType], timeout: Optional[int] = None + self, + bot: Bot, + method: TelegramMethod[TelegramType], + timeout: int | None = None, ) -> TelegramType: session = await self.create_session() @@ -175,7 +165,9 @@ class AiohttpSession(BaseSession): try: async with session.post( - url, data=form, timeout=self.timeout if timeout is None else timeout + url, + data=form, + timeout=self.timeout if timeout is None else timeout, ) as resp: raw_result = await resp.text() except asyncio.TimeoutError: @@ -183,14 +175,17 @@ class AiohttpSession(BaseSession): except ClientError as e: raise TelegramNetworkError(method=method, message=f"{type(e).__name__}: {e}") response = self.check_response( - bot=bot, method=method, status_code=resp.status, content=raw_result + bot=bot, + method=method, + status_code=resp.status, + content=raw_result, ) return cast(TelegramType, response.result) async def stream_content( self, url: str, - headers: Optional[Dict[str, Any]] = None, + headers: dict[str, Any] | None = None, timeout: int = 30, chunk_size: int = 65536, raise_for_status: bool = True, @@ -201,11 +196,14 @@ class AiohttpSession(BaseSession): session = await self.create_session() async with session.get( - url, timeout=timeout, headers=headers, raise_for_status=raise_for_status + url, + timeout=timeout, + headers=headers, + raise_for_status=raise_for_status, ) as resp: async for chunk in resp.content.iter_chunked(chunk_size): yield chunk - async def __aenter__(self) -> AiohttpSession: + async def __aenter__(self) -> Self: await self.create_session() return self diff --git a/aiogram/client/session/base.py b/aiogram/client/session/base.py index 82ec4691..a5eea81e 100644 --- a/aiogram/client/session/base.py +++ b/aiogram/client/session/base.py @@ -4,23 +4,16 @@ import abc import datetime import json import secrets +from collections.abc import AsyncGenerator, Callable from enum import Enum from http import HTTPStatus -from types import TracebackType -from typing import ( - TYPE_CHECKING, - Any, - AsyncGenerator, - Callable, - Dict, - Final, - Optional, - Type, - cast, -) +from typing import TYPE_CHECKING, Any, Final, cast from pydantic import ValidationError +from typing_extensions import Self +from aiogram.client.default import Default +from aiogram.client.telegram import PRODUCTION, TelegramAPIServer from aiogram.exceptions import ( ClientDecodeError, RestartingTelegram, @@ -35,16 +28,16 @@ from aiogram.exceptions import ( TelegramServerError, TelegramUnauthorizedError, ) +from aiogram.methods import Response, TelegramMethod +from aiogram.methods.base import TelegramType +from aiogram.types import InputFile, TelegramObject -from ...methods import Response, TelegramMethod -from ...methods.base import TelegramType -from ...types import InputFile, TelegramObject -from ..default import Default -from ..telegram import PRODUCTION, TelegramAPIServer from .middlewares.manager import RequestMiddlewareManager if TYPE_CHECKING: - from ..bot import Bot + from types import TracebackType + + from aiogram.client.bot import Bot _JsonLoads = Callable[..., Any] _JsonDumps = Callable[..., str] @@ -81,24 +74,30 @@ class BaseSession(abc.ABC): self.middleware = RequestMiddlewareManager() def check_response( - self, bot: Bot, method: TelegramMethod[TelegramType], status_code: int, content: str + self, + bot: Bot, + method: TelegramMethod[TelegramType], + status_code: int, + content: str, ) -> Response[TelegramType]: """ Check response status """ try: json_data = self.json_loads(content) - except Exception as e: + except Exception as e: # noqa: BLE001 # Handled error type can't be classified as specific error # in due to decoder can be customized and raise any exception - raise ClientDecodeError("Failed to decode object", e, content) + msg = "Failed to decode object" + raise ClientDecodeError(msg, e, content) try: response_type = Response[method.__returning__] # type: ignore response = response_type.model_validate(json_data, context={"bot": bot}) except ValidationError as e: - raise ClientDecodeError("Failed to deserialize object", e, json_data) + msg = "Failed to deserialize object" + raise ClientDecodeError(msg, e, json_data) if HTTPStatus.OK <= status_code <= HTTPStatus.IM_USED and response.ok: return response @@ -108,7 +107,9 @@ class BaseSession(abc.ABC): if parameters := response.parameters: if parameters.retry_after: raise TelegramRetryAfter( - method=method, message=description, retry_after=parameters.retry_after + method=method, + message=description, + retry_after=parameters.retry_after, ) if parameters.migrate_to_chat_id: raise TelegramMigrateToChat( @@ -143,14 +144,13 @@ class BaseSession(abc.ABC): """ Close client session """ - pass @abc.abstractmethod async def make_request( self, bot: Bot, method: TelegramMethod[TelegramType], - timeout: Optional[int] = None, + timeout: int | None = None, ) -> TelegramType: # pragma: no cover """ Make request to Telegram Bot API @@ -161,13 +161,12 @@ class BaseSession(abc.ABC): :return: :raise TelegramApiError: """ - pass @abc.abstractmethod async def stream_content( self, url: str, - headers: Optional[Dict[str, Any]] = None, + headers: dict[str, Any] | None = None, timeout: int = 30, chunk_size: int = 65536, raise_for_status: bool = True, @@ -181,7 +180,7 @@ class BaseSession(abc.ABC): self, value: Any, bot: Bot, - files: Dict[str, Any], + files: dict[str, Any], _dumps_json: bool = True, ) -> Any: """ @@ -204,7 +203,10 @@ class BaseSession(abc.ABC): for key, item in value.items() if ( prepared_item := self.prepare_value( - item, bot=bot, files=files, _dumps_json=False + item, + bot=bot, + files=files, + _dumps_json=False, ) ) is not None @@ -218,7 +220,10 @@ class BaseSession(abc.ABC): for item in value if ( prepared_item := self.prepare_value( - item, bot=bot, files=files, _dumps_json=False + item, + bot=bot, + files=files, + _dumps_json=False, ) ) is not None @@ -227,7 +232,7 @@ class BaseSession(abc.ABC): return self.json_dumps(value) return value if isinstance(value, datetime.timedelta): - now = datetime.datetime.now() + now = datetime.datetime.now() # noqa: DTZ005 return str(round((now + value).timestamp())) if isinstance(value, datetime.datetime): return str(round(value.timestamp())) @@ -248,18 +253,18 @@ class BaseSession(abc.ABC): self, bot: Bot, method: TelegramMethod[TelegramType], - timeout: Optional[int] = None, + timeout: int | None = None, ) -> TelegramType: middleware = self.middleware.wrap_middlewares(self.make_request, timeout=timeout) return cast(TelegramType, await middleware(bot, method)) - async def __aenter__(self) -> BaseSession: + async def __aenter__(self) -> Self: return self async def __aexit__( self, - exc_type: Optional[Type[BaseException]], - exc_value: Optional[BaseException], - traceback: Optional[TracebackType], + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, ) -> None: await self.close() diff --git a/aiogram/client/session/middlewares/base.py b/aiogram/client/session/middlewares/base.py index c5f3e7cf..a933b600 100644 --- a/aiogram/client/session/middlewares/base.py +++ b/aiogram/client/session/middlewares/base.py @@ -3,17 +3,17 @@ from __future__ import annotations from abc import ABC, abstractmethod from typing import TYPE_CHECKING, Protocol -from aiogram.methods import Response, TelegramMethod from aiogram.methods.base import TelegramType if TYPE_CHECKING: - from ...bot import Bot + from aiogram.client.bot import Bot + from aiogram.methods import Response, TelegramMethod class NextRequestMiddlewareType(Protocol[TelegramType]): # pragma: no cover async def __call__( self, - bot: "Bot", + bot: Bot, method: TelegramMethod[TelegramType], ) -> Response[TelegramType]: pass @@ -23,7 +23,7 @@ class RequestMiddlewareType(Protocol): # pragma: no cover async def __call__( self, make_request: NextRequestMiddlewareType[TelegramType], - bot: "Bot", + bot: Bot, method: TelegramMethod[TelegramType], ) -> Response[TelegramType]: pass @@ -38,7 +38,7 @@ class BaseRequestMiddleware(ABC): async def __call__( self, make_request: NextRequestMiddlewareType[TelegramType], - bot: "Bot", + bot: Bot, method: TelegramMethod[TelegramType], ) -> Response[TelegramType]: """ @@ -50,4 +50,3 @@ class BaseRequestMiddleware(ABC): :return: :class:`aiogram.methods.Response` """ - pass diff --git a/aiogram/client/session/middlewares/manager.py b/aiogram/client/session/middlewares/manager.py index 2346715f..14906a4f 100644 --- a/aiogram/client/session/middlewares/manager.py +++ b/aiogram/client/session/middlewares/manager.py @@ -1,7 +1,8 @@ from __future__ import annotations +from collections.abc import Callable, Sequence from functools import partial -from typing import Any, Callable, List, Optional, Sequence, Union, cast, overload +from typing import Any, cast, overload from aiogram.client.session.middlewares.base import ( NextRequestMiddlewareType, @@ -12,7 +13,7 @@ from aiogram.methods.base import TelegramType class RequestMiddlewareManager(Sequence[RequestMiddlewareType]): def __init__(self) -> None: - self._middlewares: List[RequestMiddlewareType] = [] + self._middlewares: list[RequestMiddlewareType] = [] def register( self, @@ -26,11 +27,8 @@ class RequestMiddlewareManager(Sequence[RequestMiddlewareType]): def __call__( self, - middleware: Optional[RequestMiddlewareType] = None, - ) -> Union[ - Callable[[RequestMiddlewareType], RequestMiddlewareType], - RequestMiddlewareType, - ]: + middleware: RequestMiddlewareType | None = None, + ) -> Callable[[RequestMiddlewareType], RequestMiddlewareType] | RequestMiddlewareType: if middleware is None: return self.register return self.register(middleware) @@ -44,8 +42,9 @@ class RequestMiddlewareManager(Sequence[RequestMiddlewareType]): pass def __getitem__( - self, item: Union[int, slice] - ) -> Union[RequestMiddlewareType, Sequence[RequestMiddlewareType]]: + self, + item: int | slice, + ) -> RequestMiddlewareType | Sequence[RequestMiddlewareType]: return self._middlewares[item] def __len__(self) -> int: diff --git a/aiogram/client/session/middlewares/request_logging.py b/aiogram/client/session/middlewares/request_logging.py index af7b9d6e..f46631bf 100644 --- a/aiogram/client/session/middlewares/request_logging.py +++ b/aiogram/client/session/middlewares/request_logging.py @@ -1,5 +1,5 @@ import logging -from typing import TYPE_CHECKING, Any, List, Optional, Type +from typing import TYPE_CHECKING, Any from aiogram import loggers from aiogram.methods import TelegramMethod @@ -8,19 +8,19 @@ from aiogram.methods.base import Response, TelegramType from .base import BaseRequestMiddleware, NextRequestMiddlewareType if TYPE_CHECKING: - from ...bot import Bot + from aiogram.client.bot import Bot logger = logging.getLogger(__name__) class RequestLogging(BaseRequestMiddleware): - def __init__(self, ignore_methods: Optional[List[Type[TelegramMethod[Any]]]] = None): + def __init__(self, ignore_methods: list[type[TelegramMethod[Any]]] | None = None): """ Middleware for logging outgoing requests :param ignore_methods: methods to ignore in logging middleware """ - self.ignore_methods = ignore_methods if ignore_methods else [] + self.ignore_methods = ignore_methods or [] async def __call__( self, diff --git a/aiogram/client/telegram.py b/aiogram/client/telegram.py index cfb3c49d..7e6e04b9 100644 --- a/aiogram/client/telegram.py +++ b/aiogram/client/telegram.py @@ -1,24 +1,24 @@ from abc import ABC, abstractmethod -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path -from typing import Any, Union +from typing import Any class FilesPathWrapper(ABC): @abstractmethod - def to_local(self, path: Union[Path, str]) -> Union[Path, str]: + def to_local(self, path: Path | str) -> Path | str: pass @abstractmethod - def to_server(self, path: Union[Path, str]) -> Union[Path, str]: + def to_server(self, path: Path | str) -> Path | str: pass class BareFilesPathWrapper(FilesPathWrapper): - def to_local(self, path: Union[Path, str]) -> Union[Path, str]: + def to_local(self, path: Path | str) -> Path | str: return path - def to_server(self, path: Union[Path, str]) -> Union[Path, str]: + def to_server(self, path: Path | str) -> Path | str: return path @@ -29,15 +29,18 @@ class SimpleFilesPathWrapper(FilesPathWrapper): @classmethod def _resolve( - cls, base1: Union[Path, str], base2: Union[Path, str], value: Union[Path, str] + cls, + base1: Path | str, + base2: Path | str, + value: Path | str, ) -> Path: relative = Path(value).relative_to(base1) return base2 / relative - def to_local(self, path: Union[Path, str]) -> Union[Path, str]: + def to_local(self, path: Path | str) -> Path | str: return self._resolve(base1=self.server_path, base2=self.local_path, value=path) - def to_server(self, path: Union[Path, str]) -> Union[Path, str]: + def to_server(self, path: Path | str) -> Path | str: return self._resolve(base1=self.local_path, base2=self.server_path, value=path) @@ -54,7 +57,7 @@ class TelegramAPIServer: is_local: bool = False """Mark this server is in `local mode `_.""" - wrap_local_file: FilesPathWrapper = BareFilesPathWrapper() + wrap_local_file: FilesPathWrapper = field(default=BareFilesPathWrapper()) """Callback to wrap files path in local mode""" def api_url(self, token: str, method: str) -> str: @@ -67,7 +70,7 @@ class TelegramAPIServer: """ return self.base.format(token=token, method=method) - def file_url(self, token: str, path: Union[str, Path]) -> str: + def file_url(self, token: str, path: str | Path) -> str: """ Generate URL for downloading files diff --git a/aiogram/dispatcher/dispatcher.py b/aiogram/dispatcher/dispatcher.py index 63fd33c2..a40355ab 100644 --- a/aiogram/dispatcher/dispatcher.py +++ b/aiogram/dispatcher/dispatcher.py @@ -5,28 +5,32 @@ import contextvars import signal import warnings from asyncio import CancelledError, Event, Future, Lock +from collections.abc import AsyncGenerator, Awaitable from contextlib import suppress -from typing import Any, AsyncGenerator, Awaitable, Dict, List, Optional, Set, Union +from typing import TYPE_CHECKING, Any + +from aiogram import loggers +from aiogram.exceptions import TelegramAPIError +from aiogram.fsm.middleware import FSMContextMiddleware +from aiogram.fsm.storage.base import BaseEventIsolation, BaseStorage +from aiogram.fsm.storage.memory import DisabledEventIsolation, MemoryStorage +from aiogram.fsm.strategy import FSMStrategy +from aiogram.methods import GetUpdates, TelegramMethod +from aiogram.types import Update, User +from aiogram.types.base import UNSET, UNSET_TYPE +from aiogram.types.update import UpdateTypeLookupError +from aiogram.utils.backoff import Backoff, BackoffConfig -from .. import loggers -from ..client.bot import Bot -from ..exceptions import TelegramAPIError -from ..fsm.middleware import FSMContextMiddleware -from ..fsm.storage.base import BaseEventIsolation, BaseStorage -from ..fsm.storage.memory import DisabledEventIsolation, MemoryStorage -from ..fsm.strategy import FSMStrategy -from ..methods import GetUpdates, TelegramMethod -from ..methods.base import TelegramType -from ..types import Update, User -from ..types.base import UNSET, UNSET_TYPE -from ..types.update import UpdateTypeLookupError -from ..utils.backoff import Backoff, BackoffConfig from .event.bases import UNHANDLED, SkipHandler from .event.telegram import TelegramEventObserver from .middlewares.error import ErrorsMiddleware from .middlewares.user_context import UserContextMiddleware from .router import Router +if TYPE_CHECKING: + from aiogram.client.bot import Bot + from aiogram.methods.base import TelegramType + DEFAULT_BACKOFF_CONFIG = BackoffConfig(min_delay=1.0, max_delay=5.0, factor=1.3, jitter=0.1) @@ -38,11 +42,11 @@ class Dispatcher(Router): def __init__( self, *, # * - Preventing to pass instance of Bot to the FSM storage - storage: Optional[BaseStorage] = None, + storage: BaseStorage | None = None, fsm_strategy: FSMStrategy = FSMStrategy.USER_IN_CHAT, - events_isolation: Optional[BaseEventIsolation] = None, + events_isolation: BaseEventIsolation | None = None, disable_fsm: bool = False, - name: Optional[str] = None, + name: str | None = None, **kwargs: Any, ) -> None: """ @@ -55,18 +59,18 @@ class Dispatcher(Router): then you should not use storage and events isolation :param kwargs: Other arguments, will be passed as keyword arguments to handlers """ - super(Dispatcher, self).__init__(name=name) + super().__init__(name=name) if storage and not isinstance(storage, BaseStorage): - raise TypeError( - f"FSM storage should be instance of 'BaseStorage' not {type(storage).__name__}" - ) + msg = f"FSM storage should be instance of 'BaseStorage' not {type(storage).__name__}" + raise TypeError(msg) # Telegram API provides originally only one event type - Update # For making easily interactions with events here is registered handler which helps # to separate Update to different event types like Message, CallbackQuery etc. self.update = self.observers["update"] = TelegramEventObserver( - router=self, event_name="update" + router=self, + event_name="update", ) self.update.register(self._listen_update) @@ -91,11 +95,11 @@ class Dispatcher(Router): self.update.outer_middleware(self.fsm) self.shutdown.register(self.fsm.close) - self.workflow_data: Dict[str, Any] = kwargs + self.workflow_data: dict[str, Any] = kwargs self._running_lock = Lock() - self._stop_signal: Optional[Event] = None - self._stopped_signal: Optional[Event] = None - self._handle_update_tasks: Set[asyncio.Task[Any]] = set() + self._stop_signal: Event | None = None + self._stopped_signal: Event | None = None + self._handle_update_tasks: set[asyncio.Task[Any]] = set() def __getitem__(self, item: str) -> Any: return self.workflow_data[item] @@ -106,7 +110,7 @@ class Dispatcher(Router): def __delitem__(self, key: str) -> None: del self.workflow_data[key] - def get(self, key: str, /, default: Optional[Any] = None) -> Optional[Any]: + def get(self, key: str, /, default: Any | None = None) -> Any | None: return self.workflow_data.get(key, default) @property @@ -114,13 +118,13 @@ class Dispatcher(Router): return self.fsm.storage @property - def parent_router(self) -> Optional[Router]: + def parent_router(self) -> Router | None: """ Dispatcher has no parent router and can't be included to any other routers or dispatchers :return: """ - return None # noqa: RET501 + return None @parent_router.setter def parent_router(self, value: Router) -> None: @@ -130,7 +134,8 @@ class Dispatcher(Router): :param value: :return: """ - raise RuntimeError("Dispatcher can not be attached to another Router.") + msg = "Dispatcher can not be attached to another Router." + raise RuntimeError(msg) async def feed_update(self, bot: Bot, update: Update, **kwargs: Any) -> Any: """ @@ -177,7 +182,7 @@ class Dispatcher(Router): bot.id, ) - async def feed_raw_update(self, bot: Bot, update: Dict[str, Any], **kwargs: Any) -> Any: + async def feed_raw_update(self, bot: Bot, update: dict[str, Any], **kwargs: Any) -> Any: """ Main entry point for incoming updates with automatic Dict->Update serializer @@ -194,7 +199,7 @@ class Dispatcher(Router): bot: Bot, polling_timeout: int = 30, backoff_config: BackoffConfig = DEFAULT_BACKOFF_CONFIG, - allowed_updates: Optional[List[str]] = None, + allowed_updates: list[str] | None = None, ) -> AsyncGenerator[Update, None]: """ Endless updates reader with correctly handling any server-side or connection errors. @@ -212,7 +217,7 @@ class Dispatcher(Router): while True: try: updates = await bot(get_updates, **kwargs) - except Exception as e: + except Exception as e: # noqa: BLE001 failed = True # In cases when Telegram Bot API was inaccessible don't need to stop polling # process because some developers can't make auto-restarting of the script @@ -268,6 +273,7 @@ class Dispatcher(Router): "installed not latest version of aiogram framework" f"\nUpdate: {update.model_dump_json(exclude_unset=True)}", RuntimeWarning, + stacklevel=2, ) raise SkipHandler() from e @@ -294,7 +300,11 @@ class Dispatcher(Router): loggers.event.error("Failed to make answer: %s: %s", e.__class__.__name__, e) async def _process_update( - self, bot: Bot, update: Update, call_answer: bool = True, **kwargs: Any + self, + bot: Bot, + update: Update, + call_answer: bool = True, + **kwargs: Any, ) -> bool: """ Propagate update to event listeners @@ -309,9 +319,8 @@ class Dispatcher(Router): response = await self.feed_update(bot, update, **kwargs) if call_answer and isinstance(response, TelegramMethod): await self.silent_call_request(bot=bot, result=response) - return response is not UNHANDLED - except Exception as e: + except Exception as e: # noqa: BLE001 loggers.event.exception( "Cause exception while process update id=%d by bot id=%d\n%s: %s", update.update_id, @@ -321,8 +330,13 @@ class Dispatcher(Router): ) return True # because update was processed but unsuccessful + else: + return response is not UNHANDLED + async def _process_with_semaphore( - self, handle_update: Awaitable[bool], semaphore: asyncio.Semaphore + self, + handle_update: Awaitable[bool], + semaphore: asyncio.Semaphore, ) -> bool: """ Process update with semaphore to limit concurrent tasks @@ -342,8 +356,8 @@ class Dispatcher(Router): polling_timeout: int = 30, handle_as_tasks: bool = True, backoff_config: BackoffConfig = DEFAULT_BACKOFF_CONFIG, - allowed_updates: Optional[List[str]] = None, - tasks_concurrency_limit: Optional[int] = None, + allowed_updates: list[str] | None = None, + tasks_concurrency_limit: int | None = None, **kwargs: Any, ) -> None: """ @@ -361,7 +375,10 @@ class Dispatcher(Router): """ user: User = await bot.me() loggers.dispatcher.info( - "Run polling for bot @%s id=%d - %r", user.username, bot.id, user.full_name + "Run polling for bot @%s id=%d - %r", + user.username, + bot.id, + user.full_name, ) # Create semaphore if tasks_concurrency_limit is specified @@ -382,7 +399,7 @@ class Dispatcher(Router): # Use semaphore to limit concurrent tasks await semaphore.acquire() handle_update_task = asyncio.create_task( - self._process_with_semaphore(handle_update, semaphore) + self._process_with_semaphore(handle_update, semaphore), ) else: handle_update_task = asyncio.create_task(handle_update) @@ -393,7 +410,10 @@ class Dispatcher(Router): await handle_update finally: loggers.dispatcher.info( - "Polling stopped for bot @%s id=%d - %r", user.username, bot.id, user.full_name + "Polling stopped for bot @%s id=%d - %r", + user.username, + bot.id, + user.full_name, ) async def _feed_webhook_update(self, bot: Bot, update: Update, **kwargs: Any) -> Any: @@ -413,8 +433,12 @@ class Dispatcher(Router): raise async def feed_webhook_update( - self, bot: Bot, update: Union[Update, Dict[str, Any]], _timeout: float = 55, **kwargs: Any - ) -> Optional[TelegramMethod[TelegramType]]: + self, + bot: Bot, + update: Update | dict[str, Any], + _timeout: float = 55, + **kwargs: Any, + ) -> TelegramMethod[TelegramType] | None: if not isinstance(update, Update): # Allow to use raw updates update = Update.model_validate(update, context={"bot": bot}) @@ -429,7 +453,7 @@ class Dispatcher(Router): timeout_handle = loop.call_later(_timeout, release_waiter) process_updates: Future[Any] = asyncio.ensure_future( - self._feed_webhook_update(bot=bot, update=update, **kwargs) + self._feed_webhook_update(bot=bot, update=update, **kwargs), ) process_updates.add_done_callback(release_waiter, context=ctx) @@ -440,11 +464,9 @@ class Dispatcher(Router): "For preventing this situation response into webhook returned immediately " "and handler is moved to background and still processing update.", RuntimeWarning, + stacklevel=2, ) - try: - result = task.result() - except Exception as e: - raise e + result = task.result() if isinstance(result, TelegramMethod): asyncio.ensure_future(self.silent_call_request(bot=bot, result=result)) @@ -478,7 +500,8 @@ class Dispatcher(Router): :return: """ if not self._running_lock.locked(): - raise RuntimeError("Polling is not started") + msg = "Polling is not started" + raise RuntimeError(msg) if not self._stop_signal or not self._stopped_signal: return self._stop_signal.set() @@ -499,10 +522,10 @@ class Dispatcher(Router): polling_timeout: int = 10, handle_as_tasks: bool = True, backoff_config: BackoffConfig = DEFAULT_BACKOFF_CONFIG, - allowed_updates: Optional[Union[List[str], UNSET_TYPE]] = UNSET, + allowed_updates: list[str] | UNSET_TYPE | None = UNSET, handle_signals: bool = True, close_bot_session: bool = True, - tasks_concurrency_limit: Optional[int] = None, + tasks_concurrency_limit: int | None = None, **kwargs: Any, ) -> None: """ @@ -522,12 +545,14 @@ class Dispatcher(Router): :return: """ if not bots: - raise ValueError("At least one bot instance is required to start polling") + msg = "At least one bot instance is required to start polling" + raise ValueError(msg) if "bot" in kwargs: - raise ValueError( + msg = ( "Keyword argument 'bot' is not acceptable, " "the bot instance should be passed as positional argument" ) + raise ValueError(msg) async with self._running_lock: # Prevent to run this method twice at a once if self._stop_signal is None: @@ -547,10 +572,14 @@ class Dispatcher(Router): # Signals handling is not supported on Windows # It also can't be covered on Windows loop.add_signal_handler( - signal.SIGTERM, self._signal_stop_polling, signal.SIGTERM + signal.SIGTERM, + self._signal_stop_polling, + signal.SIGTERM, ) loop.add_signal_handler( - signal.SIGINT, self._signal_stop_polling, signal.SIGINT + signal.SIGINT, + self._signal_stop_polling, + signal.SIGINT, ) workflow_data = { @@ -565,7 +594,7 @@ class Dispatcher(Router): await self.emit_startup(bot=bots[-1], **workflow_data) loggers.dispatcher.info("Start polling") try: - tasks: List[asyncio.Task[Any]] = [ + tasks: list[asyncio.Task[Any]] = [ asyncio.create_task( self._polling( bot=bot, @@ -575,7 +604,7 @@ class Dispatcher(Router): allowed_updates=allowed_updates, tasks_concurrency_limit=tasks_concurrency_limit, **workflow_data, - ) + ), ) for bot in bots ] @@ -605,10 +634,10 @@ class Dispatcher(Router): polling_timeout: int = 10, handle_as_tasks: bool = True, backoff_config: BackoffConfig = DEFAULT_BACKOFF_CONFIG, - allowed_updates: Optional[Union[List[str], UNSET_TYPE]] = UNSET, + allowed_updates: list[str] | UNSET_TYPE | None = UNSET, handle_signals: bool = True, close_bot_session: bool = True, - tasks_concurrency_limit: Optional[int] = None, + tasks_concurrency_limit: int | None = None, **kwargs: Any, ) -> None: """ @@ -638,5 +667,5 @@ class Dispatcher(Router): handle_signals=handle_signals, close_bot_session=close_bot_session, tasks_concurrency_limit=tasks_concurrency_limit, - ) + ), ) diff --git a/aiogram/dispatcher/event/bases.py b/aiogram/dispatcher/event/bases.py index 1765683a..3b09c23d 100644 --- a/aiogram/dispatcher/event/bases.py +++ b/aiogram/dispatcher/event/bases.py @@ -1,20 +1,22 @@ from __future__ import annotations -from typing import Any, Awaitable, Callable, Dict, NoReturn, Optional, TypeVar, Union +from collections.abc import Awaitable, Callable +from typing import Any, NoReturn, TypeVar from unittest.mock import sentinel -from ...types import TelegramObject -from ..middlewares.base import BaseMiddleware +from aiogram.dispatcher.middlewares.base import BaseMiddleware +from aiogram.types import TelegramObject MiddlewareEventType = TypeVar("MiddlewareEventType", bound=TelegramObject) -NextMiddlewareType = Callable[[MiddlewareEventType, Dict[str, Any]], Awaitable[Any]] -MiddlewareType = Union[ - BaseMiddleware, - Callable[ - [NextMiddlewareType[MiddlewareEventType], MiddlewareEventType, Dict[str, Any]], +NextMiddlewareType = Callable[[MiddlewareEventType, dict[str, Any]], Awaitable[Any]] +MiddlewareType = ( + BaseMiddleware + | Callable[ + [NextMiddlewareType[MiddlewareEventType], MiddlewareEventType, dict[str, Any]], Awaitable[Any], - ], -] + ] +) + UNHANDLED = sentinel.UNHANDLED REJECTED = sentinel.REJECTED @@ -28,7 +30,7 @@ class CancelHandler(Exception): pass -def skip(message: Optional[str] = None) -> NoReturn: +def skip(message: str | None = None) -> NoReturn: """ Raise an SkipHandler """ diff --git a/aiogram/dispatcher/event/event.py b/aiogram/dispatcher/event/event.py index 3cbcffef..e9782b0c 100644 --- a/aiogram/dispatcher/event/event.py +++ b/aiogram/dispatcher/event/event.py @@ -1,6 +1,7 @@ from __future__ import annotations -from typing import Any, Callable, List +from collections.abc import Callable +from typing import Any from .handler import CallbackType, HandlerObject @@ -25,7 +26,7 @@ class EventObserver: """ def __init__(self) -> None: - self.handlers: List[HandlerObject] = [] + self.handlers: list[HandlerObject] = [] def register(self, callback: CallbackType) -> None: """ diff --git a/aiogram/dispatcher/event/handler.py b/aiogram/dispatcher/event/handler.py index b95cc962..7aeaab96 100644 --- a/aiogram/dispatcher/event/handler.py +++ b/aiogram/dispatcher/event/handler.py @@ -1,10 +1,10 @@ import asyncio -import contextvars import inspect import warnings +from collections.abc import Callable from dataclasses import dataclass, field from functools import partial -from typing import Any, Callable, Dict, List, Optional, Set, Tuple +from typing import Any from magic_filter.magic import MagicFilter as OriginalMagicFilter @@ -21,7 +21,7 @@ CallbackType = Callable[..., Any] class CallableObject: callback: CallbackType awaitable: bool = field(init=False) - params: Set[str] = field(init=False) + params: set[str] = field(init=False) varkw: bool = field(init=False) def __post_init__(self) -> None: @@ -31,7 +31,7 @@ class CallableObject: self.params = {*spec.args, *spec.kwonlyargs} self.varkw = spec.varkw is not None - def _prepare_kwargs(self, kwargs: Dict[str, Any]) -> Dict[str, Any]: + def _prepare_kwargs(self, kwargs: dict[str, Any]) -> dict[str, Any]: if self.varkw: return kwargs @@ -46,7 +46,7 @@ class CallableObject: @dataclass class FilterObject(CallableObject): - magic: Optional[MagicFilter] = None + magic: MagicFilter | None = None def __post_init__(self) -> None: if isinstance(self.callback, OriginalMagicFilter): @@ -65,7 +65,7 @@ class FilterObject(CallableObject): stacklevel=6, ) - super(FilterObject, self).__post_init__() + super().__post_init__() if isinstance(self.callback, Filter): self.awaitable = True @@ -73,17 +73,17 @@ class FilterObject(CallableObject): @dataclass class HandlerObject(CallableObject): - filters: Optional[List[FilterObject]] = None - flags: Dict[str, Any] = field(default_factory=dict) + filters: list[FilterObject] | None = None + flags: dict[str, Any] = field(default_factory=dict) def __post_init__(self) -> None: - super(HandlerObject, self).__post_init__() + super().__post_init__() callback = inspect.unwrap(self.callback) if inspect.isclass(callback) and issubclass(callback, BaseHandler): self.awaitable = True self.flags.update(extract_flags_from_object(callback)) - async def check(self, *args: Any, **kwargs: Any) -> Tuple[bool, Dict[str, Any]]: + async def check(self, *args: Any, **kwargs: Any) -> tuple[bool, dict[str, Any]]: if not self.filters: return True, kwargs for event_filter in self.filters: diff --git a/aiogram/dispatcher/event/telegram.py b/aiogram/dispatcher/event/telegram.py index b0ed4070..36b3843e 100644 --- a/aiogram/dispatcher/event/telegram.py +++ b/aiogram/dispatcher/event/telegram.py @@ -1,17 +1,18 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional +from collections.abc import Callable +from typing import TYPE_CHECKING, Any from aiogram.dispatcher.middlewares.manager import MiddlewareManager +from aiogram.exceptions import UnsupportedKeywordArgument +from aiogram.filters.base import Filter -from ...exceptions import UnsupportedKeywordArgument -from ...filters.base import Filter -from ...types import TelegramObject from .bases import UNHANDLED, MiddlewareType, SkipHandler from .handler import CallbackType, FilterObject, HandlerObject if TYPE_CHECKING: from aiogram.dispatcher.router import Router + from aiogram.types import TelegramObject class TelegramEventObserver: @@ -26,7 +27,7 @@ class TelegramEventObserver: self.router: Router = router self.event_name: str = event_name - self.handlers: List[HandlerObject] = [] + self.handlers: list[HandlerObject] = [] self.middleware = MiddlewareManager() self.outer_middleware = MiddlewareManager() @@ -45,8 +46,8 @@ class TelegramEventObserver: self._handler.filters = [] self._handler.filters.extend([FilterObject(filter_) for filter_ in filters]) - def _resolve_middlewares(self) -> List[MiddlewareType[TelegramObject]]: - middlewares: List[MiddlewareType[TelegramObject]] = [] + def _resolve_middlewares(self) -> list[MiddlewareType[TelegramObject]]: + middlewares: list[MiddlewareType[TelegramObject]] = [] for router in reversed(tuple(self.router.chain_head)): observer = router.observers.get(self.event_name) if observer: @@ -58,14 +59,14 @@ class TelegramEventObserver: self, callback: CallbackType, *filters: CallbackType, - flags: Optional[Dict[str, Any]] = None, + flags: dict[str, Any] | None = None, **kwargs: Any, ) -> CallbackType: """ Register event handler """ if kwargs: - raise UnsupportedKeywordArgument( + msg = ( "Passing any additional keyword arguments to the registrar method " "is not supported.\n" "This error may be caused when you are trying to register filters like in 2.x " @@ -73,6 +74,7 @@ class TelegramEventObserver: "documentation pages.\n" f"Please remove the {set(kwargs.keys())} arguments from this call.\n" ) + raise UnsupportedKeywordArgument(msg) if flags is None: flags = {} @@ -86,13 +88,16 @@ class TelegramEventObserver: callback=callback, filters=[FilterObject(filter_) for filter_ in filters], flags=flags, - ) + ), ) return callback def wrap_outer_middleware( - self, callback: Any, event: TelegramObject, data: Dict[str, Any] + self, + callback: Any, + event: TelegramObject, + data: dict[str, Any], ) -> Any: wrapped_outer = self.middleware.wrap_middlewares( self.outer_middleware, @@ -127,7 +132,7 @@ class TelegramEventObserver: def __call__( self, *filters: CallbackType, - flags: Optional[Dict[str, Any]] = None, + flags: dict[str, Any] | None = None, **kwargs: Any, ) -> Callable[[CallbackType], CallbackType]: """ diff --git a/aiogram/dispatcher/flags.py b/aiogram/dispatcher/flags.py index aad8a29f..ad5a886b 100644 --- a/aiogram/dispatcher/flags.py +++ b/aiogram/dispatcher/flags.py @@ -1,5 +1,6 @@ +from collections.abc import Callable from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Union, cast, overload +from typing import TYPE_CHECKING, Any, Union, cast, overload from magic_filter import AttrDict, MagicFilter @@ -39,11 +40,12 @@ class FlagDecorator: def __call__( self, - value: Optional[Any] = None, + value: Any | None = None, **kwargs: Any, ) -> Union[Callable[..., Any], "FlagDecorator"]: if value and kwargs: - raise ValueError("The arguments `value` and **kwargs can not be used together") + msg = "The arguments `value` and **kwargs can not be used together" + raise ValueError(msg) if value is not None and callable(value): value.aiogram_flag = { @@ -70,20 +72,21 @@ if TYPE_CHECKING: class FlagGenerator: def __getattr__(self, name: str) -> FlagDecorator: if name[0] == "_": - raise AttributeError("Flag name must NOT start with underscore") + msg = "Flag name must NOT start with underscore" + raise AttributeError(msg) return FlagDecorator(Flag(name, True)) if TYPE_CHECKING: chat_action: _ChatActionFlagProtocol -def extract_flags_from_object(obj: Any) -> Dict[str, Any]: +def extract_flags_from_object(obj: Any) -> dict[str, Any]: if not hasattr(obj, "aiogram_flag"): return {} - return cast(Dict[str, Any], obj.aiogram_flag) + return cast(dict[str, Any], obj.aiogram_flag) -def extract_flags(handler: Union["HandlerObject", Dict[str, Any]]) -> Dict[str, Any]: +def extract_flags(handler: Union["HandlerObject", dict[str, Any]]) -> dict[str, Any]: """ Extract flags from handler or middleware context data @@ -98,10 +101,10 @@ def extract_flags(handler: Union["HandlerObject", Dict[str, Any]]) -> Dict[str, def get_flag( - handler: Union["HandlerObject", Dict[str, Any]], + handler: Union["HandlerObject", dict[str, Any]], name: str, *, - default: Optional[Any] = None, + default: Any | None = None, ) -> Any: """ Get flag by name @@ -115,7 +118,7 @@ def get_flag( return flags.get(name, default) -def check_flags(handler: Union["HandlerObject", Dict[str, Any]], magic: MagicFilter) -> Any: +def check_flags(handler: Union["HandlerObject", dict[str, Any]], magic: MagicFilter) -> Any: """ Check flags via magic filter diff --git a/aiogram/dispatcher/middlewares/base.py b/aiogram/dispatcher/middlewares/base.py index 15b0b4a3..ff34ddb3 100644 --- a/aiogram/dispatcher/middlewares/base.py +++ b/aiogram/dispatcher/middlewares/base.py @@ -1,5 +1,6 @@ from abc import ABC, abstractmethod -from typing import Any, Awaitable, Callable, Dict, TypeVar +from collections.abc import Awaitable, Callable +from typing import Any, TypeVar from aiogram.types import TelegramObject @@ -14,9 +15,9 @@ class BaseMiddleware(ABC): @abstractmethod async def __call__( self, - handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], + handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]], event: TelegramObject, - data: Dict[str, Any], + data: dict[str, Any], ) -> Any: # pragma: no cover """ Execute middleware @@ -26,4 +27,3 @@ class BaseMiddleware(ABC): :param data: Contextual data. Will be mapped to handler arguments :return: :class:`Any` """ - pass diff --git a/aiogram/dispatcher/middlewares/error.py b/aiogram/dispatcher/middlewares/error.py index 4b68c0bc..affd38f7 100644 --- a/aiogram/dispatcher/middlewares/error.py +++ b/aiogram/dispatcher/middlewares/error.py @@ -1,14 +1,16 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, cast +from collections.abc import Awaitable, Callable +from typing import TYPE_CHECKING, Any, cast + +from aiogram.dispatcher.event.bases import UNHANDLED, CancelHandler, SkipHandler +from aiogram.types import TelegramObject, Update +from aiogram.types.error_event import ErrorEvent -from ...types import TelegramObject, Update -from ...types.error_event import ErrorEvent -from ..event.bases import UNHANDLED, CancelHandler, SkipHandler from .base import BaseMiddleware if TYPE_CHECKING: - from ..router import Router + from aiogram.dispatcher.router import Router class ErrorsMiddleware(BaseMiddleware): @@ -17,9 +19,9 @@ class ErrorsMiddleware(BaseMiddleware): async def __call__( self, - handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], + handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]], event: TelegramObject, - data: Dict[str, Any], + data: dict[str, Any], ) -> Any: try: return await handler(event, data) diff --git a/aiogram/dispatcher/middlewares/manager.py b/aiogram/dispatcher/middlewares/manager.py index bcad4dee..e5cf73e7 100644 --- a/aiogram/dispatcher/middlewares/manager.py +++ b/aiogram/dispatcher/middlewares/manager.py @@ -1,5 +1,6 @@ import functools -from typing import Any, Callable, Dict, List, Optional, Sequence, Union, overload +from collections.abc import Callable, Sequence +from typing import Any, overload from aiogram.dispatcher.event.bases import ( MiddlewareEventType, @@ -12,7 +13,7 @@ from aiogram.types import TelegramObject class MiddlewareManager(Sequence[MiddlewareType[TelegramObject]]): def __init__(self) -> None: - self._middlewares: List[MiddlewareType[TelegramObject]] = [] + self._middlewares: list[MiddlewareType[TelegramObject]] = [] def register( self, @@ -26,11 +27,11 @@ class MiddlewareManager(Sequence[MiddlewareType[TelegramObject]]): def __call__( self, - middleware: Optional[MiddlewareType[TelegramObject]] = None, - ) -> Union[ - Callable[[MiddlewareType[TelegramObject]], MiddlewareType[TelegramObject]], - MiddlewareType[TelegramObject], - ]: + middleware: MiddlewareType[TelegramObject] | None = None, + ) -> ( + Callable[[MiddlewareType[TelegramObject]], MiddlewareType[TelegramObject]] + | MiddlewareType[TelegramObject] + ): if middleware is None: return self.register return self.register(middleware) @@ -44,8 +45,9 @@ class MiddlewareManager(Sequence[MiddlewareType[TelegramObject]]): pass def __getitem__( - self, item: Union[int, slice] - ) -> Union[MiddlewareType[TelegramObject], Sequence[MiddlewareType[TelegramObject]]]: + self, + item: int | slice, + ) -> MiddlewareType[TelegramObject] | Sequence[MiddlewareType[TelegramObject]]: return self._middlewares[item] def __len__(self) -> int: @@ -53,10 +55,11 @@ class MiddlewareManager(Sequence[MiddlewareType[TelegramObject]]): @staticmethod def wrap_middlewares( - middlewares: Sequence[MiddlewareType[MiddlewareEventType]], handler: CallbackType + middlewares: Sequence[MiddlewareType[MiddlewareEventType]], + handler: CallbackType, ) -> NextMiddlewareType[MiddlewareEventType]: @functools.wraps(handler) - def handler_wrapper(event: TelegramObject, kwargs: Dict[str, Any]) -> Any: + def handler_wrapper(event: TelegramObject, kwargs: dict[str, Any]) -> Any: return handler(event, **kwargs) middleware = handler_wrapper diff --git a/aiogram/dispatcher/middlewares/user_context.py b/aiogram/dispatcher/middlewares/user_context.py index 68e4b4ab..844ddd96 100644 --- a/aiogram/dispatcher/middlewares/user_context.py +++ b/aiogram/dispatcher/middlewares/user_context.py @@ -1,5 +1,6 @@ +from collections.abc import Awaitable, Callable from dataclasses import dataclass -from typing import Any, Awaitable, Callable, Dict, Optional +from typing import Any from aiogram.dispatcher.middlewares.base import BaseMiddleware from aiogram.types import ( @@ -20,29 +21,30 @@ EVENT_THREAD_ID_KEY = "event_thread_id" @dataclass(frozen=True) class EventContext: - chat: Optional[Chat] = None - user: Optional[User] = None - thread_id: Optional[int] = None - business_connection_id: Optional[str] = None + chat: Chat | None = None + user: User | None = None + thread_id: int | None = None + business_connection_id: str | None = None @property - def user_id(self) -> Optional[int]: + def user_id(self) -> int | None: return self.user.id if self.user else None @property - def chat_id(self) -> Optional[int]: + def chat_id(self) -> int | None: return self.chat.id if self.chat else None class UserContextMiddleware(BaseMiddleware): async def __call__( self, - handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], + handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]], event: TelegramObject, - data: Dict[str, Any], + data: dict[str, Any], ) -> Any: if not isinstance(event, Update): - raise RuntimeError("UserContextMiddleware got an unexpected event type!") + msg = "UserContextMiddleware got an unexpected event type!" + raise RuntimeError(msg) event_context = data[EVENT_CONTEXT_KEY] = self.resolve_event_context(event=event) # Backward compatibility @@ -116,13 +118,15 @@ class UserContextMiddleware(BaseMiddleware): ) if event.my_chat_member: return EventContext( - chat=event.my_chat_member.chat, user=event.my_chat_member.from_user + chat=event.my_chat_member.chat, + user=event.my_chat_member.from_user, ) if event.chat_member: return EventContext(chat=event.chat_member.chat, user=event.chat_member.from_user) if event.chat_join_request: return EventContext( - chat=event.chat_join_request.chat, user=event.chat_join_request.from_user + chat=event.chat_join_request.chat, + user=event.chat_join_request.from_user, ) if event.message_reaction: return EventContext( diff --git a/aiogram/dispatcher/router.py b/aiogram/dispatcher/router.py index 9a2796ba..90a2362a 100644 --- a/aiogram/dispatcher/router.py +++ b/aiogram/dispatcher/router.py @@ -1,12 +1,15 @@ from __future__ import annotations -from typing import Any, Dict, Final, Generator, List, Optional, Set +from collections.abc import Generator +from typing import TYPE_CHECKING, Any, Final -from ..types import TelegramObject from .event.bases import REJECTED, UNHANDLED from .event.event import EventObserver from .event.telegram import TelegramEventObserver +if TYPE_CHECKING: + from aiogram.types import TelegramObject + INTERNAL_UPDATE_TYPES: Final[frozenset[str]] = frozenset({"update", "error"}) @@ -21,31 +24,34 @@ class Router: - By decorator - :obj:`@router.()` """ - def __init__(self, *, name: Optional[str] = None) -> None: + def __init__(self, *, name: str | None = None) -> None: """ :param name: Optional router name, can be useful for debugging """ self.name = name or hex(id(self)) - self._parent_router: Optional[Router] = None - self.sub_routers: List[Router] = [] + self._parent_router: Router | None = None + self.sub_routers: list[Router] = [] # Observers self.message = TelegramEventObserver(router=self, event_name="message") self.edited_message = TelegramEventObserver(router=self, event_name="edited_message") self.channel_post = TelegramEventObserver(router=self, event_name="channel_post") self.edited_channel_post = TelegramEventObserver( - router=self, event_name="edited_channel_post" + router=self, + event_name="edited_channel_post", ) self.inline_query = TelegramEventObserver(router=self, event_name="inline_query") self.chosen_inline_result = TelegramEventObserver( - router=self, event_name="chosen_inline_result" + router=self, + event_name="chosen_inline_result", ) self.callback_query = TelegramEventObserver(router=self, event_name="callback_query") self.shipping_query = TelegramEventObserver(router=self, event_name="shipping_query") self.pre_checkout_query = TelegramEventObserver( - router=self, event_name="pre_checkout_query" + router=self, + event_name="pre_checkout_query", ) self.poll = TelegramEventObserver(router=self, event_name="poll") self.poll_answer = TelegramEventObserver(router=self, event_name="poll_answer") @@ -54,24 +60,30 @@ class Router: self.chat_join_request = TelegramEventObserver(router=self, event_name="chat_join_request") self.message_reaction = TelegramEventObserver(router=self, event_name="message_reaction") self.message_reaction_count = TelegramEventObserver( - router=self, event_name="message_reaction_count" + router=self, + event_name="message_reaction_count", ) self.chat_boost = TelegramEventObserver(router=self, event_name="chat_boost") self.removed_chat_boost = TelegramEventObserver( - router=self, event_name="removed_chat_boost" + router=self, + event_name="removed_chat_boost", ) self.deleted_business_messages = TelegramEventObserver( - router=self, event_name="deleted_business_messages" + router=self, + event_name="deleted_business_messages", ) self.business_connection = TelegramEventObserver( - router=self, event_name="business_connection" + router=self, + event_name="business_connection", ) self.edited_business_message = TelegramEventObserver( - router=self, event_name="edited_business_message" + router=self, + event_name="edited_business_message", ) self.business_message = TelegramEventObserver(router=self, event_name="business_message") self.purchased_paid_media = TelegramEventObserver( - router=self, event_name="purchased_paid_media" + router=self, + event_name="purchased_paid_media", ) self.errors = self.error = TelegramEventObserver(router=self, event_name="error") @@ -79,7 +91,7 @@ class Router: self.startup = EventObserver() self.shutdown = EventObserver() - self.observers: Dict[str, TelegramEventObserver] = { + self.observers: dict[str, TelegramEventObserver] = { "message": self.message, "edited_message": self.edited_message, "channel_post": self.channel_post, @@ -112,7 +124,7 @@ class Router: def __repr__(self) -> str: return f"<{self}>" - def resolve_used_update_types(self, skip_events: Optional[Set[str]] = None) -> List[str]: + def resolve_used_update_types(self, skip_events: set[str] | None = None) -> list[str]: """ Resolve registered event names @@ -121,7 +133,7 @@ class Router: :param skip_events: skip specified event names :return: set of registered names """ - handlers_in_use: Set[str] = set() + handlers_in_use: set[str] = set() if skip_events is None: skip_events = set() skip_events = {*skip_events, *INTERNAL_UPDATE_TYPES} @@ -139,7 +151,10 @@ class Router: async def _wrapped(telegram_event: TelegramObject, **data: Any) -> Any: return await self._propagate_event( - observer=observer, update_type=update_type, event=telegram_event, **data + observer=observer, + update_type=update_type, + event=telegram_event, + **data, ) if observer: @@ -148,7 +163,7 @@ class Router: async def _propagate_event( self, - observer: Optional[TelegramEventObserver], + observer: TelegramEventObserver | None, update_type: str, event: TelegramObject, **kwargs: Any, @@ -179,7 +194,7 @@ class Router: @property def chain_head(self) -> Generator[Router, None, None]: - router: Optional[Router] = self + router: Router | None = self while router: yield router router = router.parent_router @@ -191,7 +206,7 @@ class Router: yield from router.chain_tail @property - def parent_router(self) -> Optional[Router]: + def parent_router(self) -> Router | None: return self._parent_router @parent_router.setter @@ -206,16 +221,20 @@ class Router: :param router: """ if not isinstance(router, Router): - raise ValueError(f"router should be instance of Router not {type(router).__name__!r}") + msg = f"router should be instance of Router not {type(router).__name__!r}" + raise ValueError(msg) if self._parent_router: - raise RuntimeError(f"Router is already attached to {self._parent_router!r}") + msg = f"Router is already attached to {self._parent_router!r}" + raise RuntimeError(msg) if self == router: - raise RuntimeError("Self-referencing routers is not allowed") + msg = "Self-referencing routers is not allowed" + raise RuntimeError(msg) - parent: Optional[Router] = router + parent: Router | None = router while parent is not None: if parent == self: - raise RuntimeError("Circular referencing of Router is not allowed") + msg = "Circular referencing of Router is not allowed" + raise RuntimeError(msg) parent = parent.parent_router @@ -230,7 +249,8 @@ class Router: :return: """ if not routers: - raise ValueError("At least one router must be provided") + msg = "At least one router must be provided" + raise ValueError(msg) for router in routers: self.include_router(router) @@ -242,9 +262,8 @@ class Router: :return: """ if not isinstance(router, Router): - raise ValueError( - f"router should be instance of Router not {type(router).__class__.__name__}" - ) + msg = f"router should be instance of Router not {type(router).__class__.__name__}" + raise ValueError(msg) router.parent_router = self return router diff --git a/aiogram/exceptions.py b/aiogram/exceptions.py index 9f609d51..2b8efcbb 100644 --- a/aiogram/exceptions.py +++ b/aiogram/exceptions.py @@ -16,7 +16,7 @@ class DetailedAiogramError(AiogramError): Base exception for all aiogram errors with detailed message. """ - url: Optional[str] = None + url: str | None = None def __init__(self, message: str) -> None: self.message = message diff --git a/aiogram/filters/__init__.py b/aiogram/filters/__init__.py index bcadc178..e2668830 100644 --- a/aiogram/filters/__init__.py +++ b/aiogram/filters/__init__.py @@ -23,29 +23,29 @@ from .state import StateFilter BaseFilter = Filter __all__ = ( - "Filter", + "ADMINISTRATOR", + "CREATOR", + "IS_ADMIN", + "IS_MEMBER", + "IS_NOT_MEMBER", + "JOIN_TRANSITION", + "KICKED", + "LEAVE_TRANSITION", + "LEFT", + "MEMBER", + "PROMOTED_TRANSITION", + "RESTRICTED", "BaseFilter", + "ChatMemberUpdatedFilter", "Command", "CommandObject", "CommandStart", "ExceptionMessageFilter", "ExceptionTypeFilter", - "StateFilter", + "Filter", "MagicData", - "ChatMemberUpdatedFilter", - "CREATOR", - "ADMINISTRATOR", - "MEMBER", - "RESTRICTED", - "LEFT", - "KICKED", - "IS_MEMBER", - "IS_ADMIN", - "PROMOTED_TRANSITION", - "IS_NOT_MEMBER", - "JOIN_TRANSITION", - "LEAVE_TRANSITION", + "StateFilter", "and_f", - "or_f", "invert_f", + "or_f", ) diff --git a/aiogram/filters/base.py b/aiogram/filters/base.py index 94f9b6d7..34c88115 100644 --- a/aiogram/filters/base.py +++ b/aiogram/filters/base.py @@ -1,11 +1,12 @@ from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, Union +from collections.abc import Awaitable, Callable +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from aiogram.filters.logic import _InvertFilter -class Filter(ABC): +class Filter(ABC): # noqa: B024 """ If you want to register own filters like builtin filters you will need to write subclass of this class with overriding the :code:`__call__` @@ -16,11 +17,11 @@ class Filter(ABC): # This checking type-hint is needed because mypy checks validity of overrides and raises: # error: Signature of "__call__" incompatible with supertype "BaseFilter" [override] # https://mypy.readthedocs.io/en/latest/error_code_list.html#check-validity-of-overrides-override - __call__: Callable[..., Awaitable[Union[bool, Dict[str, Any]]]] + __call__: Callable[..., Awaitable[bool | dict[str, Any]]] else: # pragma: no cover @abstractmethod - async def __call__(self, *args: Any, **kwargs: Any) -> Union[bool, Dict[str, Any]]: + async def __call__(self, *args: Any, **kwargs: Any) -> bool | dict[str, Any]: """ This method should be overridden. @@ -28,21 +29,19 @@ class Filter(ABC): :return: :class:`bool` or :class:`Dict[str, Any]` """ - pass def __invert__(self) -> "_InvertFilter": from aiogram.filters.logic import invert_f return invert_f(self) - def update_handler_flags(self, flags: Dict[str, Any]) -> None: + def update_handler_flags(self, flags: dict[str, Any]) -> None: # noqa: B027 """ Also if you want to extend handler flags with using this filter you should implement this method :param flags: existing flags, can be updated directly """ - pass def _signature_to_string(self, *args: Any, **kwargs: Any) -> str: items = [repr(arg) for arg in args] diff --git a/aiogram/filters/callback_data.py b/aiogram/filters/callback_data.py index e504d50b..fc08994f 100644 --- a/aiogram/filters/callback_data.py +++ b/aiogram/filters/callback_data.py @@ -1,40 +1,30 @@ from __future__ import annotations -import sys import types import typing from decimal import Decimal from enum import Enum from fractions import Fraction -from typing import ( - TYPE_CHECKING, - Any, - ClassVar, - Dict, - Literal, - Optional, - Type, - TypeVar, - Union, -) +from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypeVar from uuid import UUID -from magic_filter import MagicFilter from pydantic import BaseModel -from pydantic.fields import FieldInfo from pydantic_core import PydanticUndefined +from typing_extensions import Self from aiogram.filters.base import Filter from aiogram.types import CallbackQuery +if TYPE_CHECKING: + from magic_filter import MagicFilter + from pydantic.fields import FieldInfo + T = TypeVar("T", bound="CallbackData") MAX_CALLBACK_LENGTH: int = 64 -_UNION_TYPES = {typing.Union} -if sys.version_info >= (3, 10): # pragma: no cover - _UNION_TYPES.add(types.UnionType) +_UNION_TYPES = {typing.Union, types.UnionType} class CallbackDataException(Exception): @@ -59,17 +49,19 @@ class CallbackData(BaseModel): def __init_subclass__(cls, **kwargs: Any) -> None: if "prefix" not in kwargs: - raise ValueError( + msg = ( f"prefix required, usage example: " f"`class {cls.__name__}(CallbackData, prefix='my_callback'): ...`" ) + raise ValueError(msg) cls.__separator__ = kwargs.pop("sep", ":") cls.__prefix__ = kwargs.pop("prefix") if cls.__separator__ in cls.__prefix__: - raise ValueError( + msg = ( f"Separator symbol {cls.__separator__!r} can not be used " f"inside prefix {cls.__prefix__!r}" ) + raise ValueError(msg) super().__init_subclass__(**kwargs) def _encode_value(self, key: str, value: Any) -> str: @@ -83,10 +75,11 @@ class CallbackData(BaseModel): return str(int(value)) if isinstance(value, (int, str, float, Decimal, Fraction)): return str(value) - raise ValueError( + msg = ( f"Attribute {key}={value!r} of type {type(value).__name__!r}" f" can not be packed to callback data" ) + raise ValueError(msg) def pack(self) -> str: """ @@ -98,21 +91,23 @@ class CallbackData(BaseModel): for key, value in self.model_dump(mode="python").items(): encoded = self._encode_value(key, value) if self.__separator__ in encoded: - raise ValueError( + msg = ( f"Separator symbol {self.__separator__!r} can not be used " f"in value {key}={encoded!r}" ) + raise ValueError(msg) result.append(encoded) callback_data = self.__separator__.join(result) if len(callback_data.encode()) > MAX_CALLBACK_LENGTH: - raise ValueError( + msg = ( f"Resulted callback data is too long! " f"len({callback_data!r}.encode()) > {MAX_CALLBACK_LENGTH}" ) + raise ValueError(msg) return callback_data @classmethod - def unpack(cls: Type[T], value: str) -> T: + def unpack(cls, value: str) -> Self: """ Parse callback data string @@ -122,22 +117,28 @@ class CallbackData(BaseModel): prefix, *parts = value.split(cls.__separator__) names = cls.model_fields.keys() if len(parts) != len(names): - raise TypeError( + msg = ( f"Callback data {cls.__name__!r} takes {len(names)} arguments " f"but {len(parts)} were given" ) + raise TypeError(msg) if prefix != cls.__prefix__: - raise ValueError(f"Bad prefix ({prefix!r} != {cls.__prefix__!r})") + msg = f"Bad prefix ({prefix!r} != {cls.__prefix__!r})" + raise ValueError(msg) payload = {} - for k, v in zip(names, parts): # type: str, Optional[str] - if field := cls.model_fields.get(k): - if v == "" and _check_field_is_nullable(field) and field.default != "": - v = field.default if field.default is not PydanticUndefined else None + for k, v in zip(names, parts, strict=True): # type: str, str + if ( + (field := cls.model_fields.get(k)) + and v == "" + and _check_field_is_nullable(field) + and field.default != "" + ): + v = field.default if field.default is not PydanticUndefined else None payload[k] = v return cls(**payload) @classmethod - def filter(cls, rule: Optional[MagicFilter] = None) -> CallbackQueryFilter: + def filter(cls, rule: MagicFilter | None = None) -> CallbackQueryFilter: """ Generates a filter for callback query with rule @@ -163,8 +164,8 @@ class CallbackQueryFilter(Filter): def __init__( self, *, - callback_data: Type[CallbackData], - rule: Optional[MagicFilter] = None, + callback_data: type[CallbackData], + rule: MagicFilter | None = None, ): """ :param callback_data: Expected type of callback data @@ -179,7 +180,7 @@ class CallbackQueryFilter(Filter): rule=self.rule, ) - async def __call__(self, query: CallbackQuery) -> Union[Literal[False], Dict[str, Any]]: + async def __call__(self, query: CallbackQuery) -> Literal[False] | dict[str, Any]: if not isinstance(query, CallbackQuery) or not query.data: return False try: @@ -204,5 +205,5 @@ def _check_field_is_nullable(field: FieldInfo) -> bool: return True return typing.get_origin(field.annotation) in _UNION_TYPES and type(None) in typing.get_args( - field.annotation + field.annotation, ) diff --git a/aiogram/filters/chat_member_updated.py b/aiogram/filters/chat_member_updated.py index 23cf0e9c..08538f55 100644 --- a/aiogram/filters/chat_member_updated.py +++ b/aiogram/filters/chat_member_updated.py @@ -1,4 +1,6 @@ -from typing import Any, Dict, Optional, TypeVar, Union +from typing import Any, TypeVar, Union + +from typing_extensions import Self from aiogram.filters.base import Filter from aiogram.types import ChatMember, ChatMemberUpdated @@ -10,11 +12,11 @@ TransitionT = TypeVar("TransitionT", bound="_MemberStatusTransition") class _MemberStatusMarker: __slots__ = ( - "name", "is_member", + "name", ) - def __init__(self, name: str, *, is_member: Optional[bool] = None) -> None: + def __init__(self, name: str, *, is_member: bool | None = None) -> None: self.name = name self.is_member = is_member @@ -22,53 +24,59 @@ class _MemberStatusMarker: result = self.name.upper() if self.is_member is not None: result = ("+" if self.is_member else "-") + result - return result # noqa: RET504 + return result - def __pos__(self: MarkerT) -> MarkerT: + def __pos__(self) -> Self: return type(self)(name=self.name, is_member=True) - def __neg__(self: MarkerT) -> MarkerT: + def __neg__(self) -> Self: return type(self)(name=self.name, is_member=False) def __or__( - self, other: Union["_MemberStatusMarker", "_MemberStatusGroupMarker"] + self, + other: Union["_MemberStatusMarker", "_MemberStatusGroupMarker"], ) -> "_MemberStatusGroupMarker": if isinstance(other, _MemberStatusMarker): return _MemberStatusGroupMarker(self, other) if isinstance(other, _MemberStatusGroupMarker): return other | self - raise TypeError( + msg = ( f"unsupported operand type(s) for |: " f"{type(self).__name__!r} and {type(other).__name__!r}" ) + raise TypeError(msg) __ror__ = __or__ def __rshift__( - self, other: Union["_MemberStatusMarker", "_MemberStatusGroupMarker"] + self, + other: Union["_MemberStatusMarker", "_MemberStatusGroupMarker"], ) -> "_MemberStatusTransition": old = _MemberStatusGroupMarker(self) if isinstance(other, _MemberStatusMarker): return _MemberStatusTransition(old=old, new=_MemberStatusGroupMarker(other)) if isinstance(other, _MemberStatusGroupMarker): return _MemberStatusTransition(old=old, new=other) - raise TypeError( + msg = ( f"unsupported operand type(s) for >>: " f"{type(self).__name__!r} and {type(other).__name__!r}" ) + raise TypeError(msg) def __lshift__( - self, other: Union["_MemberStatusMarker", "_MemberStatusGroupMarker"] + self, + other: Union["_MemberStatusMarker", "_MemberStatusGroupMarker"], ) -> "_MemberStatusTransition": new = _MemberStatusGroupMarker(self) if isinstance(other, _MemberStatusMarker): return _MemberStatusTransition(old=_MemberStatusGroupMarker(other), new=new) if isinstance(other, _MemberStatusGroupMarker): return _MemberStatusTransition(old=other, new=new) - raise TypeError( + msg = ( f"unsupported operand type(s) for <<: " f"{type(self).__name__!r} and {type(other).__name__!r}" ) + raise TypeError(msg) def __hash__(self) -> int: return hash((self.name, self.is_member)) @@ -87,44 +95,51 @@ class _MemberStatusGroupMarker: def __init__(self, *statuses: _MemberStatusMarker) -> None: if not statuses: - raise ValueError("Member status group should have at least one status included") + msg = "Member status group should have at least one status included" + raise ValueError(msg) self.statuses = frozenset(statuses) def __or__( - self: MarkerGroupT, other: Union["_MemberStatusMarker", "_MemberStatusGroupMarker"] - ) -> MarkerGroupT: + self, + other: Union["_MemberStatusMarker", "_MemberStatusGroupMarker"], + ) -> Self: if isinstance(other, _MemberStatusMarker): return type(self)(*self.statuses, other) if isinstance(other, _MemberStatusGroupMarker): return type(self)(*self.statuses, *other.statuses) - raise TypeError( + msg = ( f"unsupported operand type(s) for |: " f"{type(self).__name__!r} and {type(other).__name__!r}" ) + raise TypeError(msg) def __rshift__( - self, other: Union["_MemberStatusMarker", "_MemberStatusGroupMarker"] + self, + other: Union["_MemberStatusMarker", "_MemberStatusGroupMarker"], ) -> "_MemberStatusTransition": if isinstance(other, _MemberStatusMarker): return _MemberStatusTransition(old=self, new=_MemberStatusGroupMarker(other)) if isinstance(other, _MemberStatusGroupMarker): return _MemberStatusTransition(old=self, new=other) - raise TypeError( + msg = ( f"unsupported operand type(s) for >>: " f"{type(self).__name__!r} and {type(other).__name__!r}" ) + raise TypeError(msg) def __lshift__( - self, other: Union["_MemberStatusMarker", "_MemberStatusGroupMarker"] + self, + other: Union["_MemberStatusMarker", "_MemberStatusGroupMarker"], ) -> "_MemberStatusTransition": if isinstance(other, _MemberStatusMarker): return _MemberStatusTransition(old=_MemberStatusGroupMarker(other), new=self) if isinstance(other, _MemberStatusGroupMarker): return _MemberStatusTransition(old=other, new=self) - raise TypeError( + msg = ( f"unsupported operand type(s) for <<: " f"{type(self).__name__!r} and {type(other).__name__!r}" ) + raise TypeError(msg) def __str__(self) -> str: result = " | ".join(map(str, sorted(self.statuses, key=str))) @@ -138,8 +153,8 @@ class _MemberStatusGroupMarker: class _MemberStatusTransition: __slots__ = ( - "old", "new", + "old", ) def __init__(self, *, old: _MemberStatusGroupMarker, new: _MemberStatusGroupMarker) -> None: @@ -149,7 +164,7 @@ class _MemberStatusTransition: def __str__(self) -> str: return f"{self.old} >> {self.new}" - def __invert__(self: TransitionT) -> TransitionT: + def __invert__(self) -> Self: return type(self)(old=self.new, new=self.old) def check(self, *, old: ChatMember, new: ChatMember) -> bool: @@ -177,11 +192,9 @@ class ChatMemberUpdatedFilter(Filter): def __init__( self, - member_status_changed: Union[ - _MemberStatusMarker, - _MemberStatusGroupMarker, - _MemberStatusTransition, - ], + member_status_changed: ( + _MemberStatusMarker | _MemberStatusGroupMarker | _MemberStatusTransition + ), ): self.member_status_changed = member_status_changed @@ -190,7 +203,7 @@ class ChatMemberUpdatedFilter(Filter): member_status_changed=self.member_status_changed, ) - async def __call__(self, member_updated: ChatMemberUpdated) -> Union[bool, Dict[str, Any]]: + async def __call__(self, member_updated: ChatMemberUpdated) -> bool | dict[str, Any]: old = member_updated.old_chat_member new = member_updated.new_chat_member rule = self.member_status_changed diff --git a/aiogram/filters/command.py b/aiogram/filters/command.py index f52ac263..cce14491 100644 --- a/aiogram/filters/command.py +++ b/aiogram/filters/command.py @@ -1,31 +1,21 @@ from __future__ import annotations import re +from collections.abc import Iterable, Sequence from dataclasses import dataclass, field, replace -from typing import ( - TYPE_CHECKING, - Any, - Dict, - Iterable, - Match, - Optional, - Pattern, - Sequence, - Union, - cast, -) - -from magic_filter import MagicFilter +from re import Match, Pattern +from typing import TYPE_CHECKING, Any, cast from aiogram.filters.base import Filter from aiogram.types import BotCommand, Message from aiogram.utils.deep_linking import decode_payload if TYPE_CHECKING: + from magic_filter import MagicFilter + from aiogram import Bot -# TODO: rm type ignore after py3.8 support expiration or mypy bug fix -CommandPatternType = Union[str, re.Pattern, BotCommand] # type: ignore[type-arg] +CommandPatternType = str | re.Pattern[str] | BotCommand class CommandException(Exception): @@ -41,20 +31,20 @@ class Command(Filter): __slots__ = ( "commands", - "prefix", "ignore_case", "ignore_mention", "magic", + "prefix", ) def __init__( self, *values: CommandPatternType, - commands: Optional[Union[Sequence[CommandPatternType], CommandPatternType]] = None, + commands: Sequence[CommandPatternType] | CommandPatternType | None = None, prefix: str = "/", ignore_case: bool = False, ignore_mention: bool = False, - magic: Optional[MagicFilter] = None, + magic: MagicFilter | None = None, ): """ List of commands (string or compiled regexp patterns) @@ -74,26 +64,29 @@ class Command(Filter): commands = [commands] if not isinstance(commands, Iterable): - raise ValueError( + msg = ( "Command filter only supports str, re.Pattern, BotCommand object" " or their Iterable" ) + raise ValueError(msg) items = [] for command in (*values, *commands): if isinstance(command, BotCommand): command = command.command if not isinstance(command, (str, re.Pattern)): - raise ValueError( + msg = ( "Command filter only supports str, re.Pattern, BotCommand object" " or their Iterable" ) + raise ValueError(msg) if ignore_case and isinstance(command, str): command = command.casefold() items.append(command) if not items: - raise ValueError("At least one command should be specified") + msg = "At least one command should be specified" + raise ValueError(msg) self.commands = tuple(items) self.prefix = prefix @@ -110,11 +103,11 @@ class Command(Filter): magic=self.magic, ) - def update_handler_flags(self, flags: Dict[str, Any]) -> None: + def update_handler_flags(self, flags: dict[str, Any]) -> None: commands = flags.setdefault("commands", []) commands.append(self) - async def __call__(self, message: Message, bot: Bot) -> Union[bool, Dict[str, Any]]: + async def __call__(self, message: Message, bot: Bot) -> bool | dict[str, Any]: if not isinstance(message, Message): return False @@ -137,7 +130,8 @@ class Command(Filter): try: full_command, *args = text.split(maxsplit=1) except ValueError: - raise CommandException("not enough values to unpack") + msg = "not enough values to unpack" + raise CommandException(msg) # Separate command into valuable parts # "/command@mention" -> "/", ("command", "@", "mention") @@ -151,13 +145,15 @@ class Command(Filter): def validate_prefix(self, command: CommandObject) -> None: if command.prefix not in self.prefix: - raise CommandException("Invalid command prefix") + msg = "Invalid command prefix" + raise CommandException(msg) async def validate_mention(self, bot: Bot, command: CommandObject) -> None: if command.mention and not self.ignore_mention: me = await bot.me() if me.username and command.mention.lower() != me.username.lower(): - raise CommandException("Mention did not match") + msg = "Mention did not match" + raise CommandException(msg) def validate_command(self, command: CommandObject) -> CommandObject: for allowed_command in cast(Sequence[CommandPatternType], self.commands): @@ -174,7 +170,8 @@ class Command(Filter): if command_name == allowed_command: # String return command - raise CommandException("Command did not match pattern") + msg = "Command did not match pattern" + raise CommandException(msg) async def parse_command(self, text: str, bot: Bot) -> CommandObject: """ @@ -196,7 +193,8 @@ class Command(Filter): return command result = self.magic.resolve(command) if not result: - raise CommandException("Rejected via magic filter") + msg = "Rejected via magic filter" + raise CommandException(msg) return replace(command, magic_result=result) @@ -211,13 +209,13 @@ class CommandObject: """Command prefix""" command: str = "" """Command without prefix and mention""" - mention: Optional[str] = None + mention: str | None = None """Mention (if available)""" - args: Optional[str] = field(repr=False, default=None) + args: str | None = field(repr=False, default=None) """Command argument""" - regexp_match: Optional[Match[str]] = field(repr=False, default=None) + regexp_match: Match[str] | None = field(repr=False, default=None) """Will be presented match result if the command is presented as regexp in filter""" - magic_result: Optional[Any] = field(repr=False, default=None) + magic_result: Any | None = field(repr=False, default=None) @property def mentioned(self) -> bool: @@ -246,7 +244,7 @@ class CommandStart(Command): deep_link_encoded: bool = False, ignore_case: bool = False, ignore_mention: bool = False, - magic: Optional[MagicFilter] = None, + magic: MagicFilter | None = None, ): super().__init__( "start", @@ -287,12 +285,14 @@ class CommandStart(Command): if not self.deep_link: return command if not command.args: - raise CommandException("Deep-link was missing") + msg = "Deep-link was missing" + raise CommandException(msg) args = command.args if self.deep_link_encoded: try: args = decode_payload(args) except UnicodeDecodeError as e: - raise CommandException(f"Failed to decode Base64: {e}") + msg = f"Failed to decode Base64: {e}" + raise CommandException(msg) return replace(command, args=args) return command diff --git a/aiogram/filters/exception.py b/aiogram/filters/exception.py index 2530d751..a5109fe1 100644 --- a/aiogram/filters/exception.py +++ b/aiogram/filters/exception.py @@ -1,5 +1,6 @@ import re -from typing import Any, Dict, Pattern, Type, Union, cast +from re import Pattern +from typing import Any, cast from aiogram.filters.base import Filter from aiogram.types import TelegramObject @@ -13,15 +14,16 @@ class ExceptionTypeFilter(Filter): __slots__ = ("exceptions",) - def __init__(self, *exceptions: Type[Exception]): + def __init__(self, *exceptions: type[Exception]): """ :param exceptions: Exception type(s) """ if not exceptions: - raise ValueError("At least one exception type is required") + msg = "At least one exception type is required" + raise ValueError(msg) self.exceptions = exceptions - async def __call__(self, obj: TelegramObject) -> Union[bool, Dict[str, Any]]: + async def __call__(self, obj: TelegramObject) -> bool | dict[str, Any]: return isinstance(cast(ErrorEvent, obj).exception, self.exceptions) @@ -32,7 +34,7 @@ class ExceptionMessageFilter(Filter): __slots__ = ("pattern",) - def __init__(self, pattern: Union[str, Pattern[str]]): + def __init__(self, pattern: str | Pattern[str]): """ :param pattern: Regexp pattern """ @@ -48,7 +50,7 @@ class ExceptionMessageFilter(Filter): async def __call__( self, obj: TelegramObject, - ) -> Union[bool, Dict[str, Any]]: + ) -> bool | dict[str, Any]: result = self.pattern.match(str(cast(ErrorEvent, obj).exception)) if not result: return False diff --git a/aiogram/filters/logic.py b/aiogram/filters/logic.py index 7cd2503c..b85617f5 100644 --- a/aiogram/filters/logic.py +++ b/aiogram/filters/logic.py @@ -1,5 +1,5 @@ from abc import ABC -from typing import TYPE_CHECKING, Any, Dict, Union +from typing import TYPE_CHECKING, Any from aiogram.filters import Filter @@ -17,7 +17,7 @@ class _InvertFilter(_LogicFilter): def __init__(self, target: "FilterObject") -> None: self.target = target - async def __call__(self, *args: Any, **kwargs: Any) -> Union[bool, Dict[str, Any]]: + async def __call__(self, *args: Any, **kwargs: Any) -> bool | dict[str, Any]: return not bool(await self.target.call(*args, **kwargs)) @@ -27,7 +27,7 @@ class _AndFilter(_LogicFilter): def __init__(self, *targets: "FilterObject") -> None: self.targets = targets - async def __call__(self, *args: Any, **kwargs: Any) -> Union[bool, Dict[str, Any]]: + async def __call__(self, *args: Any, **kwargs: Any) -> bool | dict[str, Any]: final_result = {} for target in self.targets: @@ -48,7 +48,7 @@ class _OrFilter(_LogicFilter): def __init__(self, *targets: "FilterObject") -> None: self.targets = targets - async def __call__(self, *args: Any, **kwargs: Any) -> Union[bool, Dict[str, Any]]: + async def __call__(self, *args: Any, **kwargs: Any) -> bool | dict[str, Any]: for target in self.targets: result = await target.call(*args, **kwargs) if not result: diff --git a/aiogram/filters/magic_data.py b/aiogram/filters/magic_data.py index 5c0d474f..0059bf8c 100644 --- a/aiogram/filters/magic_data.py +++ b/aiogram/filters/magic_data.py @@ -18,7 +18,7 @@ class MagicData(Filter): async def __call__(self, event: TelegramObject, *args: Any, **kwargs: Any) -> Any: return self.magic_data.resolve( - AttrDict({"event": event, **dict(enumerate(args)), **kwargs}) + AttrDict({"event": event, **dict(enumerate(args)), **kwargs}), ) def __str__(self) -> str: diff --git a/aiogram/filters/state.py b/aiogram/filters/state.py index 82a141c9..5ea4cd0a 100644 --- a/aiogram/filters/state.py +++ b/aiogram/filters/state.py @@ -1,11 +1,12 @@ +from collections.abc import Sequence from inspect import isclass -from typing import Any, Dict, Optional, Sequence, Type, Union, cast +from typing import Any, cast from aiogram.filters.base import Filter from aiogram.fsm.state import State, StatesGroup from aiogram.types import TelegramObject -StateType = Union[str, None, State, StatesGroup, Type[StatesGroup]] +StateType = str | State | StatesGroup | type[StatesGroup] | None class StateFilter(Filter): @@ -17,7 +18,8 @@ class StateFilter(Filter): def __init__(self, *states: StateType) -> None: if not states: - raise ValueError("At least one state is required") + msg = "At least one state is required" + raise ValueError(msg) self.states = states @@ -27,17 +29,22 @@ class StateFilter(Filter): ) async def __call__( - self, obj: TelegramObject, raw_state: Optional[str] = None - ) -> Union[bool, Dict[str, Any]]: + self, + obj: TelegramObject, + raw_state: str | None = None, + ) -> bool | dict[str, Any]: allowed_states = cast(Sequence[StateType], self.states) for allowed_state in allowed_states: if isinstance(allowed_state, str) or allowed_state is None: - if allowed_state == "*" or raw_state == allowed_state: + if allowed_state in {"*", raw_state}: return True elif isinstance(allowed_state, (State, StatesGroup)): if allowed_state(event=obj, raw_state=raw_state): return True - elif isclass(allowed_state) and issubclass(allowed_state, StatesGroup): - if allowed_state()(event=obj, raw_state=raw_state): - return True + elif ( + isclass(allowed_state) + and issubclass(allowed_state, StatesGroup) + and allowed_state()(event=obj, raw_state=raw_state) + ): + return True return False diff --git a/aiogram/fsm/context.py b/aiogram/fsm/context.py index f353df5d..b5dd1cc3 100644 --- a/aiogram/fsm/context.py +++ b/aiogram/fsm/context.py @@ -1,4 +1,5 @@ -from typing import Any, Dict, Mapping, Optional, overload +from collections.abc import Mapping +from typing import Any, overload from aiogram.fsm.storage.base import BaseStorage, StateType, StorageKey @@ -11,27 +12,29 @@ class FSMContext: async def set_state(self, state: StateType = None) -> None: await self.storage.set_state(key=self.key, state=state) - async def get_state(self) -> Optional[str]: + async def get_state(self) -> str | None: return await self.storage.get_state(key=self.key) async def set_data(self, data: Mapping[str, Any]) -> None: await self.storage.set_data(key=self.key, data=data) - async def get_data(self) -> Dict[str, Any]: + async def get_data(self) -> dict[str, Any]: return await self.storage.get_data(key=self.key) @overload - async def get_value(self, key: str) -> Optional[Any]: ... + async def get_value(self, key: str) -> Any | None: ... @overload async def get_value(self, key: str, default: Any) -> Any: ... - async def get_value(self, key: str, default: Optional[Any] = None) -> Optional[Any]: + async def get_value(self, key: str, default: Any | None = None) -> Any | None: return await self.storage.get_value(storage_key=self.key, dict_key=key, default=default) async def update_data( - self, data: Optional[Mapping[str, Any]] = None, **kwargs: Any - ) -> Dict[str, Any]: + self, + data: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> dict[str, Any]: if data: kwargs.update(data) return await self.storage.update_data(key=self.key, data=kwargs) diff --git a/aiogram/fsm/middleware.py b/aiogram/fsm/middleware.py index de934574..41fd993f 100644 --- a/aiogram/fsm/middleware.py +++ b/aiogram/fsm/middleware.py @@ -1,4 +1,5 @@ -from typing import Any, Awaitable, Callable, Dict, Optional, cast +from collections.abc import Awaitable, Callable +from typing import Any, cast from aiogram import Bot from aiogram.dispatcher.middlewares.base import BaseMiddleware @@ -27,9 +28,9 @@ class FSMContextMiddleware(BaseMiddleware): async def __call__( self, - handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], + handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]], event: TelegramObject, - data: Dict[str, Any], + data: dict[str, Any], ) -> Any: bot: Bot = cast(Bot, data["bot"]) context = self.resolve_event_context(bot, data) @@ -45,9 +46,9 @@ class FSMContextMiddleware(BaseMiddleware): def resolve_event_context( self, bot: Bot, - data: Dict[str, Any], + data: dict[str, Any], destiny: str = DEFAULT_DESTINY, - ) -> Optional[FSMContext]: + ) -> FSMContext | None: event_context: EventContext = cast(EventContext, data.get(EVENT_CONTEXT_KEY)) return self.resolve_context( bot=bot, @@ -61,12 +62,12 @@ class FSMContextMiddleware(BaseMiddleware): def resolve_context( self, bot: Bot, - chat_id: Optional[int], - user_id: Optional[int], - thread_id: Optional[int] = None, - business_connection_id: Optional[str] = None, + chat_id: int | None, + user_id: int | None, + thread_id: int | None = None, + business_connection_id: str | None = None, destiny: str = DEFAULT_DESTINY, - ) -> Optional[FSMContext]: + ) -> FSMContext | None: if chat_id is None: chat_id = user_id @@ -92,8 +93,8 @@ class FSMContextMiddleware(BaseMiddleware): bot: Bot, chat_id: int, user_id: int, - thread_id: Optional[int] = None, - business_connection_id: Optional[str] = None, + thread_id: int | None = None, + business_connection_id: str | None = None, destiny: str = DEFAULT_DESTINY, ) -> FSMContext: return FSMContext( diff --git a/aiogram/fsm/scene.py b/aiogram/fsm/scene.py index 94def0d6..42b038c5 100644 --- a/aiogram/fsm/scene.py +++ b/aiogram/fsm/scene.py @@ -2,26 +2,15 @@ from __future__ import annotations import inspect from collections import defaultdict +from collections.abc import Mapping from dataclasses import dataclass, replace from enum import Enum, auto -from typing import ( - Any, - ClassVar, - Dict, - List, - Mapping, - Optional, - Tuple, - Type, - Union, - overload, -) +from typing import TYPE_CHECKING, Any, ClassVar, overload from typing_extensions import Self from aiogram import loggers from aiogram.dispatcher.dispatcher import Dispatcher -from aiogram.dispatcher.event.bases import NextMiddlewareType from aiogram.dispatcher.event.handler import CallableObject, CallbackType from aiogram.dispatcher.flags import extract_flags_from_object from aiogram.dispatcher.router import Router @@ -36,16 +25,20 @@ from aiogram.utils.class_attrs_resolver import ( get_sorted_mro_attrs_resolver, ) +if TYPE_CHECKING: + from aiogram.dispatcher.event.bases import NextMiddlewareType + class HistoryManager: def __init__(self, state: FSMContext, destiny: str = "scenes_history", size: int = 10): self._size = size self._state = state self._history_state = FSMContext( - storage=state.storage, key=replace(state.key, destiny=destiny) + storage=state.storage, + key=replace(state.key, destiny=destiny), ) - async def push(self, state: Optional[str], data: Dict[str, Any]) -> None: + async def push(self, state: str | None, data: dict[str, Any]) -> None: history_data = await self._history_state.get_data() history = history_data.setdefault("history", []) history.append({"state": state, "data": data}) @@ -55,7 +48,7 @@ class HistoryManager: await self._history_state.update_data(history=history) - async def pop(self) -> Optional[MemoryStorageRecord]: + async def pop(self) -> MemoryStorageRecord | None: history_data = await self._history_state.get_data() history = history_data.setdefault("history", []) if not history: @@ -70,14 +63,14 @@ class HistoryManager: loggers.scene.debug("Pop state=%s data=%s from history", state, data) return MemoryStorageRecord(state=state, data=data) - async def get(self) -> Optional[MemoryStorageRecord]: + async def get(self) -> MemoryStorageRecord | None: history_data = await self._history_state.get_data() history = history_data.setdefault("history", []) if not history: return None return MemoryStorageRecord(**history[-1]) - async def all(self) -> List[MemoryStorageRecord]: + async def all(self) -> list[MemoryStorageRecord]: history_data = await self._history_state.get_data() history = history_data.setdefault("history", []) return [MemoryStorageRecord(**item) for item in history] @@ -91,11 +84,11 @@ class HistoryManager: data = await self._state.get_data() await self.push(state, data) - async def _set_state(self, state: Optional[str], data: Dict[str, Any]) -> None: + async def _set_state(self, state: str | None, data: dict[str, Any]) -> None: await self._state.set_state(state) await self._state.set_data(data) - async def rollback(self) -> Optional[str]: + async def rollback(self) -> str | None: previous_state = await self.pop() if not previous_state: await self._set_state(None, {}) @@ -116,14 +109,14 @@ class ObserverDecorator: name: str, filters: tuple[CallbackType, ...], action: SceneAction | None = None, - after: Optional[After] = None, + after: After | None = None, ) -> None: self.name = name self.filters = filters self.action = action self.after = after - def _wrap_filter(self, target: Type[Scene] | CallbackType) -> None: + def _wrap_filter(self, target: type[Scene] | CallbackType) -> None: handlers = getattr(target, "__aiogram_handler__", None) if not handlers: handlers = [] @@ -135,7 +128,7 @@ class ObserverDecorator: handler=target, filters=self.filters, after=self.after, - ) + ), ) def _wrap_action(self, target: CallbackType) -> None: @@ -154,13 +147,14 @@ class ObserverDecorator: else: self._wrap_action(target) else: - raise TypeError("Only function or method is allowed") + msg = "Only function or method is allowed" + raise TypeError(msg) return target def leave(self) -> ActionContainer: return ActionContainer(self.name, self.filters, SceneAction.leave) - def enter(self, target: Type[Scene]) -> ActionContainer: + def enter(self, target: type[Scene]) -> ActionContainer: return ActionContainer(self.name, self.filters, SceneAction.enter, target) def exit(self) -> ActionContainer: @@ -181,9 +175,9 @@ class ActionContainer: def __init__( self, name: str, - filters: Tuple[CallbackType, ...], + filters: tuple[CallbackType, ...], action: SceneAction, - target: Optional[Union[Type[Scene], State, str]] = None, + target: type[Scene] | State | str | None = None, ) -> None: self.name = name self.filters = filters @@ -201,33 +195,27 @@ class ActionContainer: await wizard.back() +@dataclass(slots=True) class HandlerContainer: - def __init__( - self, - name: str, - handler: CallbackType, - filters: Tuple[CallbackType, ...], - after: Optional[After] = None, - ) -> None: - self.name = name - self.handler = handler - self.filters = filters - self.after = after + name: str + handler: CallbackType + filters: tuple[CallbackType, ...] + after: After | None = None -@dataclass() +@dataclass class SceneConfig: - state: Optional[str] + state: str | None """Scene state""" - handlers: List[HandlerContainer] + handlers: list[HandlerContainer] """Scene handlers""" - actions: Dict[SceneAction, Dict[str, CallableObject]] + actions: dict[SceneAction, dict[str, CallableObject]] """Scene actions""" - reset_data_on_enter: Optional[bool] = None + reset_data_on_enter: bool | None = None """Reset scene data on enter""" - reset_history_on_enter: Optional[bool] = None + reset_history_on_enter: bool | None = None """Reset scene history on enter""" - callback_query_without_state: Optional[bool] = None + callback_query_without_state: bool | None = None """Allow callback query without state""" attrs_resolver: ClassAttrsResolver = get_sorted_mro_attrs_resolver """ @@ -247,9 +235,9 @@ async def _empty_handler(*args: Any, **kwargs: Any) -> None: class SceneHandlerWrapper: def __init__( self, - scene: Type[Scene], + scene: type[Scene], handler: CallbackType, - after: Optional[After] = None, + after: After | None = None, ) -> None: self.scene = scene self.handler = CallableObject(handler) @@ -271,7 +259,7 @@ class SceneHandlerWrapper: update_type=event_update.event_type, event=event, data=kwargs, - ) + ), ) result = await self.handler.call(scene, event, **kwargs) @@ -331,7 +319,7 @@ class Scene: super().__init_subclass__(**kwargs) handlers: list[HandlerContainer] = [] - actions: defaultdict[SceneAction, Dict[str, CallableObject]] = defaultdict(dict) + actions: defaultdict[SceneAction, dict[str, CallableObject]] = defaultdict(dict) for base in cls.__bases__: if not issubclass(base, Scene): @@ -353,7 +341,7 @@ class Scene: if attrs_resolver is None: attrs_resolver = get_sorted_mro_attrs_resolver - for name, value in attrs_resolver(cls): + for _name, value in attrs_resolver(cls): if scene_handlers := getattr(value, "__aiogram_handler__", None): handlers.extend(scene_handlers) if isinstance(value, ObserverDecorator): @@ -363,7 +351,7 @@ class Scene: _empty_handler, value.filters, after=value.after, - ) + ), ) if hasattr(value, "__aiogram_action__"): for action, action_handlers in value.__aiogram_action__.items(): @@ -408,7 +396,7 @@ class Scene: router.observers[observer_name].filter(StateFilter(scene_config.state)) @classmethod - def as_router(cls, name: Optional[str] = None) -> Router: + def as_router(cls, name: str | None = None) -> Router: """ Returns the scene as a router. @@ -433,7 +421,9 @@ class Scene: """ async def enter_to_scene_handler( - event: TelegramObject, scenes: ScenesManager, **middleware_kwargs: Any + event: TelegramObject, + scenes: ScenesManager, + **middleware_kwargs: Any, ) -> None: await scenes.enter(cls, **{**handler_kwargs, **middleware_kwargs}) @@ -461,7 +451,7 @@ class SceneWizard: state: FSMContext, update_type: str, event: TelegramObject, - data: Dict[str, Any], + data: dict[str, Any], ): """ A class that represents a wizard for managing scenes in a Telegram bot. @@ -480,7 +470,7 @@ class SceneWizard: self.event = event self.data = data - self.scene: Optional[Scene] = None + self.scene: Scene | None = None async def enter(self, **kwargs: Any) -> None: """ @@ -548,7 +538,7 @@ class SceneWizard: assert self.scene_config.state is not None, "Scene state is not specified" await self.goto(self.scene_config.state, **kwargs) - async def goto(self, scene: Union[Type[Scene], State, str], **kwargs: Any) -> None: + async def goto(self, scene: type[Scene] | State | str, **kwargs: Any) -> None: """ The `goto` method transitions to a new scene. It first calls the `leave` method to perform any necessary cleanup @@ -565,13 +555,16 @@ class SceneWizard: async def _on_action(self, action: SceneAction, **kwargs: Any) -> bool: if not self.scene: - raise SceneException("Scene is not initialized") + msg = "Scene is not initialized" + raise SceneException(msg) loggers.scene.debug("Call action %r in scene %r", action.name, self.scene_config.state) action_config = self.scene_config.actions.get(action, {}) if not action_config: loggers.scene.debug( - "Action %r not found in scene %r", action.name, self.scene_config.state + "Action %r not found in scene %r", + action.name, + self.scene_config.state, ) return False @@ -597,7 +590,7 @@ class SceneWizard: """ await self.state.set_data(data=data) - async def get_data(self) -> Dict[str, Any]: + async def get_data(self) -> dict[str, Any]: """ This method returns the data stored in the current state. @@ -606,7 +599,7 @@ class SceneWizard: return await self.state.get_data() @overload - async def get_value(self, key: str) -> Optional[Any]: + async def get_value(self, key: str) -> Any | None: """ This method returns the value from key in the data of the current state. @@ -614,7 +607,6 @@ class SceneWizard: :return: A dictionary containing the data stored in the scene state. """ - pass @overload async def get_value(self, key: str, default: Any) -> Any: @@ -626,14 +618,15 @@ class SceneWizard: :return: A dictionary containing the data stored in the scene state. """ - pass - async def get_value(self, key: str, default: Optional[Any] = None) -> Optional[Any]: + async def get_value(self, key: str, default: Any | None = None) -> Any | None: return await self.state.get_value(key, default) async def update_data( - self, data: Optional[Mapping[str, Any]] = None, **kwargs: Any - ) -> Dict[str, Any]: + self, + data: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> dict[str, Any]: """ This method updates the data stored in the current state @@ -666,7 +659,7 @@ class ScenesManager: update_type: str, event: TelegramObject, state: FSMContext, - data: Dict[str, Any], + data: dict[str, Any], ) -> None: self.registry = registry self.update_type = update_type @@ -676,7 +669,7 @@ class ScenesManager: self.history = HistoryManager(self.state) - async def _get_scene(self, scene_type: Optional[Union[Type[Scene], State, str]]) -> Scene: + async def _get_scene(self, scene_type: type[Scene] | State | str | None) -> Scene: scene_type = self.registry.get(scene_type) return scene_type( wizard=SceneWizard( @@ -689,7 +682,7 @@ class ScenesManager: ), ) - async def _get_active_scene(self) -> Optional[Scene]: + async def _get_active_scene(self) -> Scene | None: state = await self.state.get_state() try: return await self._get_scene(state) @@ -698,7 +691,7 @@ class ScenesManager: async def enter( self, - scene_type: Optional[Union[Type[Scene], State, str]], + scene_type: type[Scene] | State | str | None, _check_active: bool = True, **kwargs: Any, ) -> None: @@ -753,7 +746,7 @@ class SceneRegistry: self.router = router self.register_on_add = register_on_add - self._scenes: Dict[Optional[str], Type[Scene]] = {} + self._scenes: dict[str | None, type[Scene]] = {} self._setup_middleware(router) def _setup_middleware(self, router: Router) -> None: @@ -772,7 +765,7 @@ class SceneRegistry: self, handler: NextMiddlewareType[TelegramObject], event: TelegramObject, - data: Dict[str, Any], + data: dict[str, Any], ) -> Any: assert isinstance(event, Update), "Event must be an Update instance" @@ -789,7 +782,7 @@ class SceneRegistry: self, handler: NextMiddlewareType[TelegramObject], event: TelegramObject, - data: Dict[str, Any], + data: dict[str, Any], ) -> Any: update: Update = data["event_update"] data["scenes"] = ScenesManager( @@ -801,7 +794,7 @@ class SceneRegistry: ) return await handler(event, data) - def add(self, *scenes: Type[Scene], router: Optional[Router] = None) -> None: + def add(self, *scenes: type[Scene], router: Router | None = None) -> None: """ This method adds the specified scenes to the registry and optionally registers it to the router. @@ -820,13 +813,13 @@ class SceneRegistry: :return: None """ if not scenes: - raise ValueError("At least one scene must be specified") + msg = "At least one scene must be specified" + raise ValueError(msg) for scene in scenes: if scene.__scene_config__.state in self._scenes: - raise SceneException( - f"Scene with state {scene.__scene_config__.state!r} already exists" - ) + msg = f"Scene with state {scene.__scene_config__.state!r} already exists" + raise SceneException(msg) self._scenes[scene.__scene_config__.state] = scene @@ -835,7 +828,7 @@ class SceneRegistry: elif self.register_on_add: self.router.include_router(scene.as_router()) - def register(self, *scenes: Type[Scene]) -> None: + def register(self, *scenes: type[Scene]) -> None: """ Registers one or more scenes to the SceneRegistry. @@ -844,7 +837,7 @@ class SceneRegistry: """ self.add(*scenes, router=self.router) - def get(self, scene: Optional[Union[Type[Scene], State, str]]) -> Type[Scene]: + def get(self, scene: type[Scene] | State | str | None) -> type[Scene]: """ This method returns the registered Scene object for the specified scene. The scene parameter can be either a Scene object, State object or a string representing @@ -865,18 +858,20 @@ class SceneRegistry: if isinstance(scene, State): scene = scene.state if scene is not None and not isinstance(scene, str): - raise SceneException("Scene must be a subclass of Scene, State or a string") + msg = "Scene must be a subclass of Scene, State or a string" + raise SceneException(msg) try: return self._scenes[scene] except KeyError: - raise SceneException(f"Scene {scene!r} is not registered") + msg = f"Scene {scene!r} is not registered" + raise SceneException(msg) @dataclass class After: action: SceneAction - scene: Optional[Union[Type[Scene], State, str]] = None + scene: type[Scene] | State | str | None = None @classmethod def exit(cls) -> After: @@ -887,7 +882,7 @@ class After: return cls(action=SceneAction.back) @classmethod - def goto(cls, scene: Optional[Union[Type[Scene], State, str]]) -> After: + def goto(cls, scene: type[Scene] | State | str | None) -> After: return cls(action=SceneAction.enter, scene=scene) @@ -898,7 +893,7 @@ class ObserverMarker: def __call__( self, *filters: CallbackType, - after: Optional[After] = None, + after: After | None = None, ) -> ObserverDecorator: return ObserverDecorator( self.name, diff --git a/aiogram/fsm/state.py b/aiogram/fsm/state.py index 6b8c737c..89dbf73b 100644 --- a/aiogram/fsm/state.py +++ b/aiogram/fsm/state.py @@ -1,5 +1,6 @@ import inspect -from typing import Any, Iterator, Optional, Tuple, Type, no_type_check +from collections.abc import Iterator +from typing import Any, no_type_check from aiogram.types import TelegramObject @@ -9,19 +10,20 @@ class State: State object """ - def __init__(self, state: Optional[str] = None, group_name: Optional[str] = None) -> None: + def __init__(self, state: str | None = None, group_name: str | None = None) -> None: self._state = state self._group_name = group_name - self._group: Optional[Type[StatesGroup]] = None + self._group: type[StatesGroup] | None = None @property - def group(self) -> "Type[StatesGroup]": + def group(self) -> "type[StatesGroup]": if not self._group: - raise RuntimeError("This state is not in any group.") + msg = "This state is not in any group." + raise RuntimeError(msg) return self._group @property - def state(self) -> Optional[str]: + def state(self) -> str | None: if self._state is None or self._state == "*": return self._state @@ -34,12 +36,13 @@ class State: return f"{group}:{self._state}" - def set_parent(self, group: "Type[StatesGroup]") -> None: + def set_parent(self, group: "type[StatesGroup]") -> None: if not issubclass(group, StatesGroup): - raise ValueError("Group must be subclass of StatesGroup") + msg = "Group must be subclass of StatesGroup" + raise ValueError(msg) self._group = group - def __set_name__(self, owner: "Type[StatesGroup]", name: str) -> None: + def __set_name__(self, owner: "type[StatesGroup]", name: str) -> None: if self._state is None: self._state = name self.set_parent(owner) @@ -49,12 +52,12 @@ class State: __repr__ = __str__ - def __call__(self, event: TelegramObject, raw_state: Optional[str] = None) -> bool: + def __call__(self, event: TelegramObject, raw_state: str | None = None) -> bool: if self.state == "*": return True return raw_state == self.state - def __eq__(self, other: Any) -> bool: + def __eq__(self, other: object) -> bool: if isinstance(other, self.__class__): return self.state == other.state if isinstance(other, str): @@ -66,13 +69,13 @@ class State: class StatesGroupMeta(type): - __parent__: "Optional[Type[StatesGroup]]" - __childs__: "Tuple[Type[StatesGroup], ...]" - __states__: Tuple[State, ...] - __state_names__: Tuple[str, ...] - __all_childs__: Tuple[Type["StatesGroup"], ...] - __all_states__: Tuple[State, ...] - __all_states_names__: Tuple[str, ...] + __parent__: type["StatesGroup"] | None + __childs__: tuple[type["StatesGroup"], ...] + __states__: tuple[State, ...] + __state_names__: tuple[str, ...] + __all_childs__: tuple[type["StatesGroup"], ...] + __all_states__: tuple[State, ...] + __all_states_names__: tuple[str, ...] @no_type_check def __new__(mcs, name, bases, namespace, **kwargs): @@ -81,7 +84,7 @@ class StatesGroupMeta(type): states = [] childs = [] - for name, arg in namespace.items(): + for arg in namespace.values(): if isinstance(arg, State): states.append(arg) elif inspect.isclass(arg) and issubclass(arg, StatesGroup): @@ -106,10 +109,10 @@ class StatesGroupMeta(type): @property def __full_group_name__(cls) -> str: if cls.__parent__: - return ".".join((cls.__parent__.__full_group_name__, cls.__name__)) + return f"{cls.__parent__.__full_group_name__}.{cls.__name__}" return cls.__name__ - def _prepare_child(cls, child: Type["StatesGroup"]) -> Type["StatesGroup"]: + def _prepare_child(cls, child: type["StatesGroup"]) -> type["StatesGroup"]: """Prepare child. While adding `cls` for its children, we also need to recalculate @@ -123,19 +126,19 @@ class StatesGroupMeta(type): child.__all_states_names__ = child._get_all_states_names() return child - def _get_all_childs(cls) -> Tuple[Type["StatesGroup"], ...]: + def _get_all_childs(cls) -> tuple[type["StatesGroup"], ...]: result = cls.__childs__ for child in cls.__childs__: result += child.__childs__ return result - def _get_all_states(cls) -> Tuple[State, ...]: + def _get_all_states(cls) -> tuple[State, ...]: result = cls.__states__ for group in cls.__childs__: result += group.__all_states__ return result - def _get_all_states_names(cls) -> Tuple[str, ...]: + def _get_all_states_names(cls) -> tuple[str, ...]: return tuple(state.state for state in cls.__all_states__ if state.state) def __contains__(cls, item: Any) -> bool: @@ -156,12 +159,12 @@ class StatesGroupMeta(type): class StatesGroup(metaclass=StatesGroupMeta): @classmethod - def get_root(cls) -> Type["StatesGroup"]: + def get_root(cls) -> type["StatesGroup"]: if cls.__parent__ is None: return cls return cls.__parent__.get_root() - def __call__(self, event: TelegramObject, raw_state: Optional[str] = None) -> bool: + def __call__(self, event: TelegramObject, raw_state: str | None = None) -> bool: return raw_state in type(self).__all_states_names__ def __str__(self) -> str: diff --git a/aiogram/fsm/storage/base.py b/aiogram/fsm/storage/base.py index 7a1059d6..4b2f0258 100644 --- a/aiogram/fsm/storage/base.py +++ b/aiogram/fsm/storage/base.py @@ -1,20 +1,12 @@ from abc import ABC, abstractmethod +from collections.abc import AsyncGenerator, Mapping from contextlib import asynccontextmanager from dataclasses import dataclass -from typing import ( - Any, - AsyncGenerator, - Dict, - Literal, - Mapping, - Optional, - Union, - overload, -) +from typing import Any, Literal, overload from aiogram.fsm.state import State -StateType = Optional[Union[str, State]] +StateType = str | State | None DEFAULT_DESTINY = "default" @@ -24,8 +16,8 @@ class StorageKey: bot_id: int chat_id: int user_id: int - thread_id: Optional[int] = None - business_connection_id: Optional[str] = None + thread_id: int | None = None + business_connection_id: str | None = None destiny: str = DEFAULT_DESTINY @@ -36,7 +28,7 @@ class KeyBuilder(ABC): def build( self, key: StorageKey, - part: Optional[Literal["data", "state", "lock"]] = None, + part: Literal["data", "state", "lock"] | None = None, ) -> str: """ Build key to be used in storage's db queries @@ -45,7 +37,6 @@ class KeyBuilder(ABC): :param part: part of the record :return: key to be used in storage's db queries """ - pass class DefaultKeyBuilder(KeyBuilder): @@ -84,7 +75,7 @@ class DefaultKeyBuilder(KeyBuilder): def build( self, key: StorageKey, - part: Optional[Literal["data", "state", "lock"]] = None, + part: Literal["data", "state", "lock"] | None = None, ) -> str: parts = [self.prefix] if self.with_bot_id: @@ -121,17 +112,15 @@ class BaseStorage(ABC): :param key: storage key :param state: new state """ - pass @abstractmethod - async def get_state(self, key: StorageKey) -> Optional[str]: + async def get_state(self, key: StorageKey) -> str | None: """ Get key state :param key: storage key :return: current state """ - pass @abstractmethod async def set_data(self, key: StorageKey, data: Mapping[str, Any]) -> None: @@ -141,20 +130,18 @@ class BaseStorage(ABC): :param key: storage key :param data: new data """ - pass @abstractmethod - async def get_data(self, key: StorageKey) -> Dict[str, Any]: + async def get_data(self, key: StorageKey) -> dict[str, Any]: """ Get current data for key :param key: storage key :return: current data """ - pass @overload - async def get_value(self, storage_key: StorageKey, dict_key: str) -> Optional[Any]: + async def get_value(self, storage_key: StorageKey, dict_key: str) -> Any | None: """ Get single value from data by key @@ -162,7 +149,6 @@ class BaseStorage(ABC): :param dict_key: value key :return: value stored in key of dict or ``None`` """ - pass @overload async def get_value(self, storage_key: StorageKey, dict_key: str, default: Any) -> Any: @@ -174,15 +160,17 @@ class BaseStorage(ABC): :param default: default value to return :return: value stored in key of dict or default """ - pass async def get_value( - self, storage_key: StorageKey, dict_key: str, default: Optional[Any] = None - ) -> Optional[Any]: + self, + storage_key: StorageKey, + dict_key: str, + default: Any | None = None, + ) -> Any | None: data = await self.get_data(storage_key) return data.get(dict_key, default) - async def update_data(self, key: StorageKey, data: Mapping[str, Any]) -> Dict[str, Any]: + async def update_data(self, key: StorageKey, data: Mapping[str, Any]) -> dict[str, Any]: """ Update date in the storage for key (like dict.update) @@ -200,7 +188,6 @@ class BaseStorage(ABC): """ Close storage (database connection, file or etc.) """ - pass class BaseEventIsolation(ABC): diff --git a/aiogram/fsm/storage/memory.py b/aiogram/fsm/storage/memory.py index b5eebe4a..7f6df34e 100644 --- a/aiogram/fsm/storage/memory.py +++ b/aiogram/fsm/storage/memory.py @@ -1,18 +1,10 @@ from asyncio import Lock from collections import defaultdict +from collections.abc import AsyncGenerator, Hashable, Mapping from contextlib import asynccontextmanager from copy import copy from dataclasses import dataclass, field -from typing import ( - Any, - AsyncGenerator, - DefaultDict, - Dict, - Hashable, - Mapping, - Optional, - overload, -) +from typing import Any, overload from aiogram.exceptions import DataNotDictLikeError from aiogram.fsm.state import State @@ -26,8 +18,8 @@ from aiogram.fsm.storage.base import ( @dataclass class MemoryStorageRecord: - data: Dict[str, Any] = field(default_factory=dict) - state: Optional[str] = None + data: dict[str, Any] = field(default_factory=dict) + state: str | None = None class MemoryStorage(BaseStorage): @@ -41,8 +33,8 @@ class MemoryStorage(BaseStorage): """ def __init__(self) -> None: - self.storage: DefaultDict[StorageKey, MemoryStorageRecord] = defaultdict( - MemoryStorageRecord + self.storage: defaultdict[StorageKey, MemoryStorageRecord] = defaultdict( + MemoryStorageRecord, ) async def close(self) -> None: @@ -51,28 +43,30 @@ class MemoryStorage(BaseStorage): async def set_state(self, key: StorageKey, state: StateType = None) -> None: self.storage[key].state = state.state if isinstance(state, State) else state - async def get_state(self, key: StorageKey) -> Optional[str]: + async def get_state(self, key: StorageKey) -> str | None: return self.storage[key].state async def set_data(self, key: StorageKey, data: Mapping[str, Any]) -> None: if not isinstance(data, dict): - raise DataNotDictLikeError( - f"Data must be a dict or dict-like object, got {type(data).__name__}" - ) + msg = f"Data must be a dict or dict-like object, got {type(data).__name__}" + raise DataNotDictLikeError(msg) self.storage[key].data = data.copy() - async def get_data(self, key: StorageKey) -> Dict[str, Any]: + async def get_data(self, key: StorageKey) -> dict[str, Any]: return self.storage[key].data.copy() @overload - async def get_value(self, storage_key: StorageKey, dict_key: str) -> Optional[Any]: ... + async def get_value(self, storage_key: StorageKey, dict_key: str) -> Any | None: ... @overload async def get_value(self, storage_key: StorageKey, dict_key: str, default: Any) -> Any: ... async def get_value( - self, storage_key: StorageKey, dict_key: str, default: Optional[Any] = None - ) -> Optional[Any]: + self, + storage_key: StorageKey, + dict_key: str, + default: Any | None = None, + ) -> Any | None: data = self.storage[storage_key].data return copy(data.get(dict_key, default)) @@ -89,7 +83,7 @@ class DisabledEventIsolation(BaseEventIsolation): class SimpleEventIsolation(BaseEventIsolation): def __init__(self) -> None: # TODO: Unused locks cleaner is needed - self._locks: DefaultDict[Hashable, Lock] = defaultdict(Lock) + self._locks: defaultdict[Hashable, Lock] = defaultdict(Lock) @asynccontextmanager async def lock(self, key: StorageKey) -> AsyncGenerator[None, None]: diff --git a/aiogram/fsm/storage/mongo.py b/aiogram/fsm/storage/mongo.py index ab1bf9fe..531996cb 100644 --- a/aiogram/fsm/storage/mongo.py +++ b/aiogram/fsm/storage/mongo.py @@ -1,4 +1,5 @@ -from typing import Any, Dict, Mapping, Optional, cast +from collections.abc import Mapping +from typing import Any, cast from motor.motor_asyncio import AsyncIOMotorClient @@ -27,7 +28,7 @@ class MongoStorage(BaseStorage): def __init__( self, client: AsyncIOMotorClient, - key_builder: Optional[KeyBuilder] = None, + key_builder: KeyBuilder | None = None, db_name: str = "aiogram_fsm", collection_name: str = "states_and_data", ) -> None: @@ -46,7 +47,10 @@ class MongoStorage(BaseStorage): @classmethod def from_url( - cls, url: str, connection_kwargs: Optional[Dict[str, Any]] = None, **kwargs: Any + cls, + url: str, + connection_kwargs: dict[str, Any] | None = None, + **kwargs: Any, ) -> "MongoStorage": """ Create an instance of :class:`MongoStorage` with specifying the connection string @@ -65,7 +69,7 @@ class MongoStorage(BaseStorage): """Cleanup client resources and disconnect from MongoDB.""" self._client.close() - def resolve_state(self, value: StateType) -> Optional[str]: + def resolve_state(self, value: StateType) -> str | None: if value is None: return None if isinstance(value, State): @@ -90,7 +94,7 @@ class MongoStorage(BaseStorage): upsert=True, ) - async def get_state(self, key: StorageKey) -> Optional[str]: + async def get_state(self, key: StorageKey) -> str | None: document_id = self._key_builder.build(key) document = await self._collection.find_one({"_id": document_id}) if document is None: @@ -99,9 +103,8 @@ class MongoStorage(BaseStorage): async def set_data(self, key: StorageKey, data: Mapping[str, Any]) -> None: if not isinstance(data, dict): - raise DataNotDictLikeError( - f"Data must be a dict or dict-like object, got {type(data).__name__}" - ) + msg = f"Data must be a dict or dict-like object, got {type(data).__name__}" + raise DataNotDictLikeError(msg) document_id = self._key_builder.build(key) if not data: @@ -120,14 +123,14 @@ class MongoStorage(BaseStorage): upsert=True, ) - async def get_data(self, key: StorageKey) -> Dict[str, Any]: + async def get_data(self, key: StorageKey) -> dict[str, Any]: document_id = self._key_builder.build(key) document = await self._collection.find_one({"_id": document_id}) if document is None or not document.get("data"): return {} - return cast(Dict[str, Any], document["data"]) + return cast(dict[str, Any], document["data"]) - async def update_data(self, key: StorageKey, data: Mapping[str, Any]) -> Dict[str, Any]: + async def update_data(self, key: StorageKey, data: Mapping[str, Any]) -> dict[str, Any]: document_id = self._key_builder.build(key) update_with = {f"data.{key}": value for key, value in data.items()} update_result = await self._collection.find_one_and_update( diff --git a/aiogram/fsm/storage/pymongo.py b/aiogram/fsm/storage/pymongo.py index 89db25ce..15b38eb3 100644 --- a/aiogram/fsm/storage/pymongo.py +++ b/aiogram/fsm/storage/pymongo.py @@ -1,4 +1,5 @@ -from typing import Any, Dict, Mapping, Optional, cast +from collections.abc import Mapping +from typing import Any, cast from pymongo import AsyncMongoClient @@ -21,7 +22,7 @@ class PyMongoStorage(BaseStorage): def __init__( self, client: AsyncMongoClient[Any], - key_builder: Optional[KeyBuilder] = None, + key_builder: KeyBuilder | None = None, db_name: str = "aiogram_fsm", collection_name: str = "states_and_data", ) -> None: @@ -40,7 +41,10 @@ class PyMongoStorage(BaseStorage): @classmethod def from_url( - cls, url: str, connection_kwargs: Optional[Dict[str, Any]] = None, **kwargs: Any + cls, + url: str, + connection_kwargs: dict[str, Any] | None = None, + **kwargs: Any, ) -> "PyMongoStorage": """ Create an instance of :class:`PyMongoStorage` with specifying the connection string @@ -59,7 +63,7 @@ class PyMongoStorage(BaseStorage): """Cleanup client resources and disconnect from MongoDB.""" return await self._client.close() - def resolve_state(self, value: StateType) -> Optional[str]: + def resolve_state(self, value: StateType) -> str | None: if value is None: return None if isinstance(value, State): @@ -84,18 +88,17 @@ class PyMongoStorage(BaseStorage): upsert=True, ) - async def get_state(self, key: StorageKey) -> Optional[str]: + async def get_state(self, key: StorageKey) -> str | None: document_id = self._key_builder.build(key) document = await self._collection.find_one({"_id": document_id}) if document is None: return None - return cast(Optional[str], document.get("state")) + return cast(str | None, document.get("state")) async def set_data(self, key: StorageKey, data: Mapping[str, Any]) -> None: if not isinstance(data, dict): - raise DataNotDictLikeError( - f"Data must be a dict or dict-like object, got {type(data).__name__}" - ) + msg = f"Data must be a dict or dict-like object, got {type(data).__name__}" + raise DataNotDictLikeError(msg) document_id = self._key_builder.build(key) if not data: @@ -114,14 +117,14 @@ class PyMongoStorage(BaseStorage): upsert=True, ) - async def get_data(self, key: StorageKey) -> Dict[str, Any]: + async def get_data(self, key: StorageKey) -> dict[str, Any]: document_id = self._key_builder.build(key) document = await self._collection.find_one({"_id": document_id}) if document is None or not document.get("data"): return {} - return cast(Dict[str, Any], document["data"]) + return cast(dict[str, Any], document["data"]) - async def update_data(self, key: StorageKey, data: Mapping[str, Any]) -> Dict[str, Any]: + async def update_data(self, key: StorageKey, data: Mapping[str, Any]) -> dict[str, Any]: document_id = self._key_builder.build(key) update_with = {f"data.{key}": value for key, value in data.items()} update_result = await self._collection.find_one_and_update( @@ -133,4 +136,4 @@ class PyMongoStorage(BaseStorage): ) if not update_result: await self._collection.delete_one({"_id": document_id}) - return cast(Dict[str, Any], update_result.get("data", {})) + return cast(dict[str, Any], update_result.get("data", {})) diff --git a/aiogram/fsm/storage/redis.py b/aiogram/fsm/storage/redis.py index 4136972d..1647ab3c 100644 --- a/aiogram/fsm/storage/redis.py +++ b/aiogram/fsm/storage/redis.py @@ -1,6 +1,7 @@ import json +from collections.abc import AsyncGenerator, Callable, Mapping from contextlib import asynccontextmanager -from typing import Any, AsyncGenerator, Callable, Dict, Mapping, Optional, cast +from typing import Any, cast from redis.asyncio.client import Redis from redis.asyncio.connection import ConnectionPool @@ -31,9 +32,9 @@ class RedisStorage(BaseStorage): def __init__( self, redis: Redis, - key_builder: Optional[KeyBuilder] = None, - state_ttl: Optional[ExpiryT] = None, - data_ttl: Optional[ExpiryT] = None, + key_builder: KeyBuilder | None = None, + state_ttl: ExpiryT | None = None, + data_ttl: ExpiryT | None = None, json_loads: _JsonLoads = json.loads, json_dumps: _JsonDumps = json.dumps, ) -> None: @@ -54,7 +55,10 @@ class RedisStorage(BaseStorage): @classmethod def from_url( - cls, url: str, connection_kwargs: Optional[Dict[str, Any]] = None, **kwargs: Any + cls, + url: str, + connection_kwargs: dict[str, Any] | None = None, + **kwargs: Any, ) -> "RedisStorage": """ Create an instance of :class:`RedisStorage` with specifying the connection string @@ -94,12 +98,12 @@ class RedisStorage(BaseStorage): async def get_state( self, key: StorageKey, - ) -> Optional[str]: + ) -> str | None: redis_key = self.key_builder.build(key, "state") value = await self.redis.get(redis_key) if isinstance(value, bytes): return value.decode("utf-8") - return cast(Optional[str], value) + return cast(str | None, value) async def set_data( self, @@ -107,9 +111,8 @@ class RedisStorage(BaseStorage): data: Mapping[str, Any], ) -> None: if not isinstance(data, dict): - raise DataNotDictLikeError( - f"Data must be a dict or dict-like object, got {type(data).__name__}" - ) + msg = f"Data must be a dict or dict-like object, got {type(data).__name__}" + raise DataNotDictLikeError(msg) redis_key = self.key_builder.build(key, "data") if not data: @@ -124,22 +127,22 @@ class RedisStorage(BaseStorage): async def get_data( self, key: StorageKey, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: redis_key = self.key_builder.build(key, "data") value = await self.redis.get(redis_key) if value is None: return {} if isinstance(value, bytes): value = value.decode("utf-8") - return cast(Dict[str, Any], self.json_loads(value)) + return cast(dict[str, Any], self.json_loads(value)) class RedisEventIsolation(BaseEventIsolation): def __init__( self, redis: Redis, - key_builder: Optional[KeyBuilder] = None, - lock_kwargs: Optional[Dict[str, Any]] = None, + key_builder: KeyBuilder | None = None, + lock_kwargs: dict[str, Any] | None = None, ) -> None: if key_builder is None: key_builder = DefaultKeyBuilder() @@ -153,7 +156,7 @@ class RedisEventIsolation(BaseEventIsolation): def from_url( cls, url: str, - connection_kwargs: Optional[Dict[str, Any]] = None, + connection_kwargs: dict[str, Any] | None = None, **kwargs: Any, ) -> "RedisEventIsolation": if connection_kwargs is None: diff --git a/aiogram/fsm/strategy.py b/aiogram/fsm/strategy.py index da2d94e7..6f3559e2 100644 --- a/aiogram/fsm/strategy.py +++ b/aiogram/fsm/strategy.py @@ -1,5 +1,4 @@ from enum import Enum, auto -from typing import Optional, Tuple class FSMStrategy(Enum): @@ -23,8 +22,8 @@ def apply_strategy( strategy: FSMStrategy, chat_id: int, user_id: int, - thread_id: Optional[int] = None, -) -> Tuple[int, int, Optional[int]]: + thread_id: int | None = None, +) -> tuple[int, int, int | None]: if strategy == FSMStrategy.CHAT: return chat_id, chat_id, None if strategy == FSMStrategy.GLOBAL_USER: diff --git a/aiogram/handlers/base.py b/aiogram/handlers/base.py index 0eb1b420..afc7cd06 100644 --- a/aiogram/handlers/base.py +++ b/aiogram/handlers/base.py @@ -1,7 +1,7 @@ from __future__ import annotations from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, Any, Dict, Generic, TypeVar, cast +from typing import TYPE_CHECKING, Any, Generic, TypeVar, cast from aiogram.types import Update @@ -14,7 +14,7 @@ T = TypeVar("T") class BaseHandlerMixin(Generic[T]): if TYPE_CHECKING: event: T - data: Dict[str, Any] + data: dict[str, Any] class BaseHandler(BaseHandlerMixin[T], ABC): @@ -24,7 +24,7 @@ class BaseHandler(BaseHandlerMixin[T], ABC): def __init__(self, event: T, **kwargs: Any) -> None: self.event: T = event - self.data: Dict[str, Any] = kwargs + self.data: dict[str, Any] = kwargs @property def bot(self) -> Bot: @@ -32,7 +32,8 @@ class BaseHandler(BaseHandlerMixin[T], ABC): if "bot" in self.data: return cast(Bot, self.data["bot"]) - raise RuntimeError("Bot instance not found in the context") + msg = "Bot instance not found in the context" + raise RuntimeError(msg) @property def update(self) -> Update: diff --git a/aiogram/handlers/callback_query.py b/aiogram/handlers/callback_query.py index e2a3a25e..acea76d9 100644 --- a/aiogram/handlers/callback_query.py +++ b/aiogram/handlers/callback_query.py @@ -29,14 +29,14 @@ class CallbackQueryHandler(BaseHandler[CallbackQuery], ABC): return self.event.from_user @property - def message(self) -> Optional[MaybeInaccessibleMessage]: + def message(self) -> MaybeInaccessibleMessage | None: """ Is alias for `event.message` """ return self.event.message @property - def callback_data(self) -> Optional[str]: + def callback_data(self) -> str | None: """ Is alias for `event.data` """ diff --git a/aiogram/handlers/message.py b/aiogram/handlers/message.py index 4fbecec2..8660dde9 100644 --- a/aiogram/handlers/message.py +++ b/aiogram/handlers/message.py @@ -12,7 +12,7 @@ class MessageHandler(BaseHandler[Message], ABC): """ @property - def from_user(self) -> Optional[User]: + def from_user(self) -> User | None: return self.event.from_user @property @@ -22,7 +22,7 @@ class MessageHandler(BaseHandler[Message], ABC): class MessageHandlerCommandMixin(BaseHandlerMixin[Message]): @property - def command(self) -> Optional[CommandObject]: + def command(self) -> CommandObject | None: if "command" in self.data: return cast(CommandObject, self.data["command"]) return None diff --git a/aiogram/handlers/poll.py b/aiogram/handlers/poll.py index 2183d844..273254b7 100644 --- a/aiogram/handlers/poll.py +++ b/aiogram/handlers/poll.py @@ -1,5 +1,4 @@ from abc import ABC -from typing import List from aiogram.handlers import BaseHandler from aiogram.types import Poll, PollOption @@ -15,5 +14,5 @@ class PollHandler(BaseHandler[Poll], ABC): return self.event.question @property - def options(self) -> List[PollOption]: + def options(self) -> list[PollOption]: return self.event.options diff --git a/aiogram/utils/auth_widget.py b/aiogram/utils/auth_widget.py index 080183e9..4793f28c 100644 --- a/aiogram/utils/auth_widget.py +++ b/aiogram/utils/auth_widget.py @@ -1,6 +1,6 @@ import hashlib import hmac -from typing import Any, Dict +from typing import Any def check_signature(token: str, hash: str, **kwargs: Any) -> bool: @@ -17,12 +17,14 @@ def check_signature(token: str, hash: str, **kwargs: Any) -> bool: secret = hashlib.sha256(token.encode("utf-8")) check_string = "\n".join(f"{k}={kwargs[k]}" for k in sorted(kwargs)) hmac_string = hmac.new( - secret.digest(), check_string.encode("utf-8"), digestmod=hashlib.sha256 + secret.digest(), + check_string.encode("utf-8"), + digestmod=hashlib.sha256, ).hexdigest() return hmac_string == hash -def check_integrity(token: str, data: Dict[str, Any]) -> bool: +def check_integrity(token: str, data: dict[str, Any]) -> bool: """ Verify the authentication and the integrity of the data received on user's auth diff --git a/aiogram/utils/backoff.py b/aiogram/utils/backoff.py index ebe08e97..5fc1c2d2 100644 --- a/aiogram/utils/backoff.py +++ b/aiogram/utils/backoff.py @@ -13,9 +13,11 @@ class BackoffConfig: def __post_init__(self) -> None: if self.max_delay <= self.min_delay: - raise ValueError("`max_delay` should be greater than `min_delay`") + msg = "`max_delay` should be greater than `min_delay`" + raise ValueError(msg) if self.factor <= 1: - raise ValueError("`factor` should be greater than 1") + msg = "`factor` should be greater than 1" + raise ValueError(msg) class Backoff: diff --git a/aiogram/utils/callback_answer.py b/aiogram/utils/callback_answer.py index 1515a7e6..5cf30598 100644 --- a/aiogram/utils/callback_answer.py +++ b/aiogram/utils/callback_answer.py @@ -1,4 +1,5 @@ -from typing import Any, Awaitable, Callable, Dict, Optional, Union +from collections.abc import Awaitable, Callable +from typing import Any from aiogram import BaseMiddleware, loggers from aiogram.dispatcher.flags import get_flag @@ -12,10 +13,10 @@ class CallbackAnswer: self, answered: bool, disabled: bool = False, - text: Optional[str] = None, - show_alert: Optional[bool] = None, - url: Optional[str] = None, - cache_time: Optional[int] = None, + text: str | None = None, + show_alert: bool | None = None, + url: str | None = None, + cache_time: int | None = None, ) -> None: """ Callback answer configuration @@ -48,7 +49,8 @@ class CallbackAnswer: @disabled.setter def disabled(self, value: bool) -> None: if self._answered: - raise CallbackAnswerException("Can't change disabled state after answer") + msg = "Can't change disabled state after answer" + raise CallbackAnswerException(msg) self._disabled = value @property @@ -59,7 +61,7 @@ class CallbackAnswer: return self._answered @property - def text(self) -> Optional[str]: + def text(self) -> str | None: """ Response text :return: @@ -67,48 +69,52 @@ class CallbackAnswer: return self._text @text.setter - def text(self, value: Optional[str]) -> None: + def text(self, value: str | None) -> None: if self._answered: - raise CallbackAnswerException("Can't change text after answer") + msg = "Can't change text after answer" + raise CallbackAnswerException(msg) self._text = value @property - def show_alert(self) -> Optional[bool]: + def show_alert(self) -> bool | None: """ Whether to display an alert """ return self._show_alert @show_alert.setter - def show_alert(self, value: Optional[bool]) -> None: + def show_alert(self, value: bool | None) -> None: if self._answered: - raise CallbackAnswerException("Can't change show_alert after answer") + msg = "Can't change show_alert after answer" + raise CallbackAnswerException(msg) self._show_alert = value @property - def url(self) -> Optional[str]: + def url(self) -> str | None: """ Game url """ return self._url @url.setter - def url(self, value: Optional[str]) -> None: + def url(self, value: str | None) -> None: if self._answered: - raise CallbackAnswerException("Can't change url after answer") + msg = "Can't change url after answer" + raise CallbackAnswerException(msg) self._url = value @property - def cache_time(self) -> Optional[int]: + def cache_time(self) -> int | None: """ Response cache time """ return self._cache_time @cache_time.setter - def cache_time(self, value: Optional[int]) -> None: + def cache_time(self, value: int | None) -> None: if self._answered: - raise CallbackAnswerException("Can't change cache_time after answer") + msg = "Can't change cache_time after answer" + raise CallbackAnswerException(msg) self._cache_time = value def __str__(self) -> str: @@ -131,10 +137,10 @@ class CallbackAnswerMiddleware(BaseMiddleware): def __init__( self, pre: bool = False, - text: Optional[str] = None, - show_alert: Optional[bool] = None, - url: Optional[str] = None, - cache_time: Optional[int] = None, + text: str | None = None, + show_alert: bool | None = None, + url: str | None = None, + cache_time: int | None = None, ) -> None: """ Inner middleware for callback query handlers, can be useful in bots with a lot of callback @@ -154,15 +160,15 @@ class CallbackAnswerMiddleware(BaseMiddleware): async def __call__( self, - handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], + handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]], event: TelegramObject, - data: Dict[str, Any], + data: dict[str, Any], ) -> Any: if not isinstance(event, CallbackQuery): return await handler(event, data) callback_answer = data["callback_answer"] = self.construct_callback_answer( - properties=get_flag(data, "callback_answer") + properties=get_flag(data, "callback_answer"), ) if not callback_answer.disabled and callback_answer.answered: @@ -174,7 +180,8 @@ class CallbackAnswerMiddleware(BaseMiddleware): await self.answer(event, callback_answer) def construct_callback_answer( - self, properties: Optional[Union[Dict[str, Any], bool]] + self, + properties: dict[str, Any] | bool | None, ) -> CallbackAnswer: pre, disabled, text, show_alert, url, cache_time = ( self.pre, diff --git a/aiogram/utils/chat_action.py b/aiogram/utils/chat_action.py index a2f3b465..fdb55b13 100644 --- a/aiogram/utils/chat_action.py +++ b/aiogram/utils/chat_action.py @@ -2,9 +2,10 @@ import asyncio import logging import time from asyncio import Event, Lock +from collections.abc import Awaitable, Callable from contextlib import suppress from types import TracebackType -from typing import Any, Awaitable, Callable, Dict, Optional, Type, Union +from typing import Any from aiogram import BaseMiddleware, Bot from aiogram.dispatcher.flags import get_flag @@ -32,8 +33,8 @@ class ChatActionSender: self, *, bot: Bot, - chat_id: Union[str, int], - message_thread_id: Optional[int] = None, + chat_id: str | int, + message_thread_id: int | None = None, action: str = "typing", interval: float = DEFAULT_INTERVAL, initial_sleep: float = DEFAULT_INITIAL_SLEEP, @@ -56,7 +57,7 @@ class ChatActionSender: self._lock = Lock() self._close_event = Event() self._closed_event = Event() - self._task: Optional[asyncio.Task[Any]] = None + self._task: asyncio.Task[Any] | None = None @property def running(self) -> bool: @@ -108,7 +109,8 @@ class ChatActionSender: self._close_event.clear() self._closed_event.clear() if self.running: - raise RuntimeError("Already running") + msg = "Already running" + raise RuntimeError(msg) self._task = asyncio.create_task(self._worker()) async def _stop(self) -> None: @@ -126,18 +128,18 @@ class ChatActionSender: async def __aexit__( self, - exc_type: Optional[Type[BaseException]], - exc_value: Optional[BaseException], - traceback: Optional[TracebackType], + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, ) -> Any: await self._stop() @classmethod def typing( cls, - chat_id: Union[int, str], + chat_id: int | str, bot: Bot, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, interval: float = DEFAULT_INTERVAL, initial_sleep: float = DEFAULT_INITIAL_SLEEP, ) -> "ChatActionSender": @@ -154,9 +156,9 @@ class ChatActionSender: @classmethod def upload_photo( cls, - chat_id: Union[int, str], + chat_id: int | str, bot: Bot, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, interval: float = DEFAULT_INTERVAL, initial_sleep: float = DEFAULT_INITIAL_SLEEP, ) -> "ChatActionSender": @@ -173,9 +175,9 @@ class ChatActionSender: @classmethod def record_video( cls, - chat_id: Union[int, str], + chat_id: int | str, bot: Bot, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, interval: float = DEFAULT_INTERVAL, initial_sleep: float = DEFAULT_INITIAL_SLEEP, ) -> "ChatActionSender": @@ -192,9 +194,9 @@ class ChatActionSender: @classmethod def upload_video( cls, - chat_id: Union[int, str], + chat_id: int | str, bot: Bot, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, interval: float = DEFAULT_INTERVAL, initial_sleep: float = DEFAULT_INITIAL_SLEEP, ) -> "ChatActionSender": @@ -211,9 +213,9 @@ class ChatActionSender: @classmethod def record_voice( cls, - chat_id: Union[int, str], + chat_id: int | str, bot: Bot, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, interval: float = DEFAULT_INTERVAL, initial_sleep: float = DEFAULT_INITIAL_SLEEP, ) -> "ChatActionSender": @@ -230,9 +232,9 @@ class ChatActionSender: @classmethod def upload_voice( cls, - chat_id: Union[int, str], + chat_id: int | str, bot: Bot, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, interval: float = DEFAULT_INTERVAL, initial_sleep: float = DEFAULT_INITIAL_SLEEP, ) -> "ChatActionSender": @@ -249,9 +251,9 @@ class ChatActionSender: @classmethod def upload_document( cls, - chat_id: Union[int, str], + chat_id: int | str, bot: Bot, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, interval: float = DEFAULT_INTERVAL, initial_sleep: float = DEFAULT_INITIAL_SLEEP, ) -> "ChatActionSender": @@ -268,9 +270,9 @@ class ChatActionSender: @classmethod def choose_sticker( cls, - chat_id: Union[int, str], + chat_id: int | str, bot: Bot, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, interval: float = DEFAULT_INTERVAL, initial_sleep: float = DEFAULT_INITIAL_SLEEP, ) -> "ChatActionSender": @@ -287,9 +289,9 @@ class ChatActionSender: @classmethod def find_location( cls, - chat_id: Union[int, str], + chat_id: int | str, bot: Bot, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, interval: float = DEFAULT_INTERVAL, initial_sleep: float = DEFAULT_INITIAL_SLEEP, ) -> "ChatActionSender": @@ -306,9 +308,9 @@ class ChatActionSender: @classmethod def record_video_note( cls, - chat_id: Union[int, str], + chat_id: int | str, bot: Bot, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, interval: float = DEFAULT_INTERVAL, initial_sleep: float = DEFAULT_INITIAL_SLEEP, ) -> "ChatActionSender": @@ -325,9 +327,9 @@ class ChatActionSender: @classmethod def upload_video_note( cls, - chat_id: Union[int, str], + chat_id: int | str, bot: Bot, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, interval: float = DEFAULT_INTERVAL, initial_sleep: float = DEFAULT_INITIAL_SLEEP, ) -> "ChatActionSender": @@ -349,9 +351,9 @@ class ChatActionMiddleware(BaseMiddleware): async def __call__( self, - handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], + handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]], event: TelegramObject, - data: Dict[str, Any], + data: dict[str, Any], ) -> Any: if not isinstance(event, Message): return await handler(event, data) diff --git a/aiogram/utils/chat_member.py b/aiogram/utils/chat_member.py index 7ac666c5..817f1732 100644 --- a/aiogram/utils/chat_member.py +++ b/aiogram/utils/chat_member.py @@ -1,7 +1,6 @@ -from typing import Tuple, Type, Union +from typing import Annotated from pydantic import Field, TypeAdapter -from typing_extensions import Annotated from aiogram.types import ( ChatMember, @@ -13,22 +12,22 @@ from aiogram.types import ( ChatMemberRestricted, ) -ChatMemberUnion = Union[ - ChatMemberOwner, - ChatMemberAdministrator, - ChatMemberMember, - ChatMemberRestricted, - ChatMemberLeft, - ChatMemberBanned, -] +ChatMemberUnion = ( + ChatMemberOwner + | ChatMemberAdministrator + | ChatMemberMember + | ChatMemberRestricted + | ChatMemberLeft + | ChatMemberBanned +) -ChatMemberCollection = Tuple[Type[ChatMember], ...] +ChatMemberCollection = tuple[type[ChatMember], ...] ChatMemberAdapter: TypeAdapter[ChatMemberUnion] = TypeAdapter( Annotated[ ChatMemberUnion, Field(discriminator="status"), - ] + ], ) ADMINS: ChatMemberCollection = (ChatMemberOwner, ChatMemberAdministrator) diff --git a/aiogram/utils/class_attrs_resolver.py b/aiogram/utils/class_attrs_resolver.py index 83d0da45..5ffd624b 100644 --- a/aiogram/utils/class_attrs_resolver.py +++ b/aiogram/utils/class_attrs_resolver.py @@ -1,7 +1,8 @@ import inspect +from collections.abc import Generator from dataclasses import dataclass from operator import itemgetter -from typing import Any, Generator, NamedTuple, Protocol +from typing import Any, NamedTuple, Protocol from aiogram.utils.dataclass import dataclass_kwargs diff --git a/aiogram/utils/dataclass.py b/aiogram/utils/dataclass.py index 96b9a202..acfab6f7 100644 --- a/aiogram/utils/dataclass.py +++ b/aiogram/utils/dataclass.py @@ -9,16 +9,16 @@ from typing import Any, Union def dataclass_kwargs( - init: Union[bool, None] = None, - repr: Union[bool, None] = None, - eq: Union[bool, None] = None, - order: Union[bool, None] = None, - unsafe_hash: Union[bool, None] = None, - frozen: Union[bool, None] = None, - match_args: Union[bool, None] = None, - kw_only: Union[bool, None] = None, - slots: Union[bool, None] = None, - weakref_slot: Union[bool, None] = None, + init: bool | None = None, + repr: bool | None = None, + eq: bool | None = None, + order: bool | None = None, + unsafe_hash: bool | None = None, + frozen: bool | None = None, + match_args: bool | None = None, + kw_only: bool | None = None, + slots: bool | None = None, + weakref_slot: bool | None = None, ) -> dict[str, Any]: """ Generates a dictionary of keyword arguments that can be passed to a Python @@ -48,13 +48,12 @@ def dataclass_kwargs( params["frozen"] = frozen # Added in 3.10 - if sys.version_info >= (3, 10): - if match_args is not None: - params["match_args"] = match_args - if kw_only is not None: - params["kw_only"] = kw_only - if slots is not None: - params["slots"] = slots + if match_args is not None: + params["match_args"] = match_args + if kw_only is not None: + params["kw_only"] = kw_only + if slots is not None: + params["slots"] = slots # Added in 3.11 if sys.version_info >= (3, 11): diff --git a/aiogram/utils/deep_linking.py b/aiogram/utils/deep_linking.py index b35cddce..80a97bec 100644 --- a/aiogram/utils/deep_linking.py +++ b/aiogram/utils/deep_linking.py @@ -1,22 +1,24 @@ from __future__ import annotations __all__ = [ - "create_start_link", - "create_startgroup_link", - "create_startapp_link", "create_deep_link", + "create_start_link", + "create_startapp_link", + "create_startgroup_link", "create_telegram_link", - "encode_payload", "decode_payload", + "encode_payload", ] import re -from typing import TYPE_CHECKING, Callable, Literal, Optional, cast +from typing import TYPE_CHECKING, Literal, Optional, cast from aiogram.utils.link import create_telegram_link from aiogram.utils.payload import decode_payload, encode_payload if TYPE_CHECKING: + from collections.abc import Callable + from aiogram import Bot BAD_PATTERN = re.compile(r"[^a-zA-Z0-9-_]") @@ -26,7 +28,7 @@ async def create_start_link( bot: Bot, payload: str, encode: bool = False, - encoder: Optional[Callable[[bytes], bytes]] = None, + encoder: Callable[[bytes], bytes] | None = None, ) -> str: """ Create 'start' deep link with your payload. @@ -53,7 +55,7 @@ async def create_startgroup_link( bot: Bot, payload: str, encode: bool = False, - encoder: Optional[Callable[[bytes], bytes]] = None, + encoder: Callable[[bytes], bytes] | None = None, ) -> str: """ Create 'startgroup' deep link with your payload. @@ -80,8 +82,8 @@ async def create_startapp_link( bot: Bot, payload: str, encode: bool = False, - app_name: Optional[str] = None, - encoder: Optional[Callable[[bytes], bytes]] = None, + app_name: str | None = None, + encoder: Callable[[bytes], bytes] | None = None, ) -> str: """ Create 'startapp' deep link with your payload. @@ -115,9 +117,9 @@ def create_deep_link( username: str, link_type: Literal["start", "startgroup", "startapp"], payload: str, - app_name: Optional[str] = None, + app_name: str | None = None, encode: bool = False, - encoder: Optional[Callable[[bytes], bytes]] = None, + encoder: Callable[[bytes], bytes] | None = None, ) -> str: """ Create deep link. @@ -137,13 +139,15 @@ def create_deep_link( payload = encode_payload(payload, encoder=encoder) if re.search(BAD_PATTERN, payload): - raise ValueError( + msg = ( "Wrong payload! Only A-Z, a-z, 0-9, _ and - are allowed. " "Pass `encode=True` or encode payload manually." ) + raise ValueError(msg) if len(payload) > 64: - raise ValueError("Payload must be up to 64 characters long.") + msg = "Payload must be up to 64 characters long." + raise ValueError(msg) if not app_name: deep_link = create_telegram_link(username, **{cast(str, link_type): payload}) diff --git a/aiogram/utils/formatting.py b/aiogram/utils/formatting.py index 88fa82b0..22b8723b 100644 --- a/aiogram/utils/formatting.py +++ b/aiogram/utils/formatting.py @@ -1,16 +1,8 @@ +from __future__ import annotations + import textwrap -from typing import ( - Any, - ClassVar, - Dict, - Generator, - Iterable, - Iterator, - List, - Optional, - Tuple, - Type, -) +from collections.abc import Generator, Iterable, Iterator +from typing import TYPE_CHECKING, Any, ClassVar from typing_extensions import Self @@ -35,7 +27,7 @@ class Text(Iterable[NodeType]): Simple text element """ - type: ClassVar[Optional[str]] = None + type: ClassVar[str | None] = None __slots__ = ("_body", "_params") @@ -44,16 +36,16 @@ class Text(Iterable[NodeType]): *body: NodeType, **params: Any, ) -> None: - self._body: Tuple[NodeType, ...] = body - self._params: Dict[str, Any] = params + self._body: tuple[NodeType, ...] = body + self._params: dict[str, Any] = params @classmethod - def from_entities(cls, text: str, entities: List[MessageEntity]) -> "Text": + def from_entities(cls, text: str, entities: list[MessageEntity]) -> Text: return cls( *_unparse_entities( text=add_surrogates(text), entities=sorted(entities, key=lambda item: item.offset) if entities else [], - ) + ), ) def render( @@ -62,7 +54,7 @@ class Text(Iterable[NodeType]): _offset: int = 0, _sort: bool = True, _collect_entities: bool = True, - ) -> Tuple[str, List[MessageEntity]]: + ) -> tuple[str, list[MessageEntity]]: """ Render elements tree as text with entities list @@ -108,7 +100,7 @@ class Text(Iterable[NodeType]): entities_key: str = "entities", replace_parse_mode: bool = True, parse_mode_key: str = "parse_mode", - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """ Render element tree as keyword arguments for usage in an API call, for example: @@ -124,7 +116,7 @@ class Text(Iterable[NodeType]): :return: """ text_value, entities_value = self.render() - result: Dict[str, Any] = { + result: dict[str, Any] = { text_key: text_value, entities_key: entities_value, } @@ -132,7 +124,7 @@ class Text(Iterable[NodeType]): result[parse_mode_key] = None return result - def as_caption_kwargs(self, *, replace_parse_mode: bool = True) -> Dict[str, Any]: + def as_caption_kwargs(self, *, replace_parse_mode: bool = True) -> dict[str, Any]: """ Shortcut for :meth:`as_kwargs` for usage with API calls that take ``caption`` as a parameter. @@ -151,7 +143,7 @@ class Text(Iterable[NodeType]): replace_parse_mode=replace_parse_mode, ) - def as_poll_question_kwargs(self, *, replace_parse_mode: bool = True) -> Dict[str, Any]: + def as_poll_question_kwargs(self, *, replace_parse_mode: bool = True) -> dict[str, Any]: """ Shortcut for :meth:`as_kwargs` for usage with method :class:`aiogram.methods.send_poll.SendPoll`. @@ -171,7 +163,7 @@ class Text(Iterable[NodeType]): replace_parse_mode=replace_parse_mode, ) - def as_poll_explanation_kwargs(self, *, replace_parse_mode: bool = True) -> Dict[str, Any]: + def as_poll_explanation_kwargs(self, *, replace_parse_mode: bool = True) -> dict[str, Any]: """ Shortcut for :meth:`as_kwargs` for usage with method :class:`aiogram.methods.send_poll.SendPoll`. @@ -196,7 +188,7 @@ class Text(Iterable[NodeType]): replace_parse_mode=replace_parse_mode, ) - def as_gift_text_kwargs(self, *, replace_parse_mode: bool = True) -> Dict[str, Any]: + def as_gift_text_kwargs(self, *, replace_parse_mode: bool = True) -> dict[str, Any]: """ Shortcut for :meth:`as_kwargs` for usage with method :class:`aiogram.methods.send_gift.SendGift`. @@ -252,7 +244,7 @@ class Text(Iterable[NodeType]): args_str = textwrap.indent("\n" + args_str + "\n", " ") return f"{type(self).__name__}({args_str})" - def __add__(self, other: NodeType) -> "Text": + def __add__(self, other: NodeType) -> Text: if isinstance(other, Text) and other.type == self.type and self._params == other._params: return type(self)(*self, *other, **self._params) if type(self) is Text and isinstance(other, str): @@ -266,9 +258,10 @@ class Text(Iterable[NodeType]): text, _ = self.render(_collect_entities=False) return sizeof(text) - def __getitem__(self, item: slice) -> "Text": + def __getitem__(self, item: slice) -> Text: if not isinstance(item, slice): - raise TypeError("Can only be sliced") + msg = "Can only be sliced" + raise TypeError(msg) if (item.start is None or item.start == 0) and item.stop is None: return self.replace(*self._body) start = 0 if item.start is None else item.start @@ -313,9 +306,11 @@ class HashTag(Text): def __init__(self, *body: NodeType, **params: Any) -> None: if len(body) != 1: - raise ValueError("Hashtag can contain only one element") + msg = "Hashtag can contain only one element" + raise ValueError(msg) if not isinstance(body[0], str): - raise ValueError("Hashtag can contain only string") + msg = "Hashtag can contain only string" + raise ValueError(msg) if not body[0].startswith("#"): body = ("#" + body[0],) super().__init__(*body, **params) @@ -337,9 +332,11 @@ class CashTag(Text): def __init__(self, *body: NodeType, **params: Any) -> None: if len(body) != 1: - raise ValueError("Cashtag can contain only one element") + msg = "Cashtag can contain only one element" + raise ValueError(msg) if not isinstance(body[0], str): - raise ValueError("Cashtag can contain only string") + msg = "Cashtag can contain only string" + raise ValueError(msg) if not body[0].startswith("$"): body = ("$" + body[0],) super().__init__(*body, **params) @@ -469,7 +466,7 @@ class Pre(Text): type = MessageEntityType.PRE - def __init__(self, *body: NodeType, language: Optional[str] = None, **params: Any) -> None: + def __init__(self, *body: NodeType, language: str | None = None, **params: Any) -> None: super().__init__(*body, language=language, **params) @@ -537,7 +534,7 @@ class ExpandableBlockQuote(Text): type = MessageEntityType.EXPANDABLE_BLOCKQUOTE -NODE_TYPES: Dict[Optional[str], Type[Text]] = { +NODE_TYPES: dict[str | None, type[Text]] = { Text.type: Text, HashTag.type: HashTag, CashTag.type: CashTag, @@ -570,15 +567,16 @@ def _apply_entity(entity: MessageEntity, *nodes: NodeType) -> NodeType: """ node_type = NODE_TYPES.get(entity.type, Text) return node_type( - *nodes, **entity.model_dump(exclude={"type", "offset", "length"}, warnings=False) + *nodes, + **entity.model_dump(exclude={"type", "offset", "length"}, warnings=False), ) def _unparse_entities( text: bytes, - entities: List[MessageEntity], - offset: Optional[int] = None, - length: Optional[int] = None, + entities: list[MessageEntity], + offset: int | None = None, + length: int | None = None, ) -> Generator[NodeType, None, None]: if offset is None: offset = 0 @@ -615,8 +613,7 @@ def as_line(*items: NodeType, end: str = "\n", sep: str = "") -> Text: nodes = [] for item in items[:-1]: nodes.extend([item, sep]) - nodes.append(items[-1]) - nodes.append(end) + nodes.extend([items[-1], end]) else: nodes = [*items, end] return Text(*nodes) diff --git a/aiogram/utils/i18n/__init__.py b/aiogram/utils/i18n/__init__.py index e48a4c7f..16033e42 100644 --- a/aiogram/utils/i18n/__init__.py +++ b/aiogram/utils/i18n/__init__.py @@ -8,14 +8,14 @@ from .middleware import ( ) __all__ = ( + "ConstI18nMiddleware", + "FSMI18nMiddleware", "I18n", "I18nMiddleware", "SimpleI18nMiddleware", - "ConstI18nMiddleware", - "FSMI18nMiddleware", + "get_i18n", "gettext", "lazy_gettext", - "ngettext", "lazy_ngettext", - "get_i18n", + "ngettext", ) diff --git a/aiogram/utils/i18n/context.py b/aiogram/utils/i18n/context.py index 245fee34..77b7baeb 100644 --- a/aiogram/utils/i18n/context.py +++ b/aiogram/utils/i18n/context.py @@ -7,7 +7,8 @@ from aiogram.utils.i18n.lazy_proxy import LazyProxy def get_i18n() -> I18n: i18n = I18n.get_current(no_error=True) if i18n is None: - raise LookupError("I18n context is not set") + msg = "I18n context is not set" + raise LookupError(msg) return i18n diff --git a/aiogram/utils/i18n/core.py b/aiogram/utils/i18n/core.py index db7c7979..3cdf8072 100644 --- a/aiogram/utils/i18n/core.py +++ b/aiogram/utils/i18n/core.py @@ -1,23 +1,27 @@ +from __future__ import annotations + import gettext -import os from contextlib import contextmanager from contextvars import ContextVar from pathlib import Path -from typing import Dict, Generator, Optional, Tuple, Union +from typing import TYPE_CHECKING from aiogram.utils.i18n.lazy_proxy import LazyProxy from aiogram.utils.mixins import ContextInstanceMixin +if TYPE_CHECKING: + from collections.abc import Generator + class I18n(ContextInstanceMixin["I18n"]): def __init__( self, *, - path: Union[str, Path], + path: str | Path, default_locale: str = "en", domain: str = "messages", ) -> None: - self.path = path + self.path = Path(path) self.default_locale = default_locale self.domain = domain self.ctx_locale = ContextVar("aiogram_ctx_locale", default=default_locale) @@ -43,7 +47,7 @@ class I18n(ContextInstanceMixin["I18n"]): self.ctx_locale.reset(ctx_token) @contextmanager - def context(self) -> Generator["I18n", None, None]: + def context(self) -> Generator[I18n, None, None]: """ Use I18n context """ @@ -53,24 +57,25 @@ class I18n(ContextInstanceMixin["I18n"]): finally: self.reset_current(token) - def find_locales(self) -> Dict[str, gettext.GNUTranslations]: + def find_locales(self) -> dict[str, gettext.GNUTranslations]: """ Load all compiled locales from path :return: dict with locales """ - translations: Dict[str, gettext.GNUTranslations] = {} + translations: dict[str, gettext.GNUTranslations] = {} - for name in os.listdir(self.path): - if not os.path.isdir(os.path.join(self.path, name)): + for name in self.path.iterdir(): + if not (self.path / name).is_dir(): continue - mo_path = os.path.join(self.path, name, "LC_MESSAGES", self.domain + ".mo") + mo_path = 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) - elif os.path.exists(mo_path[:-2] + "po"): # pragma: no cover - raise RuntimeError(f"Found locale '{name}' but this language is not compiled!") + if mo_path.exists(): + with mo_path.open("rb") as fp: + translations[name.name] = gettext.GNUTranslations(fp) + elif mo_path.with_suffix(".po").exists(): # pragma: no cover + msg = f"Found locale '{name.name}' but this language is not compiled!" + raise RuntimeError(msg) return translations @@ -81,7 +86,7 @@ class I18n(ContextInstanceMixin["I18n"]): self.locales = self.find_locales() @property - def available_locales(self) -> Tuple[str, ...]: + def available_locales(self) -> tuple[str, ...]: """ list of loaded locales @@ -90,7 +95,11 @@ class I18n(ContextInstanceMixin["I18n"]): return tuple(self.locales.keys()) def gettext( - self, singular: str, plural: Optional[str] = None, n: int = 1, locale: Optional[str] = None + self, + singular: str, + plural: str | None = None, + n: int = 1, + locale: str | None = None, ) -> str: """ Get text @@ -107,7 +116,7 @@ class I18n(ContextInstanceMixin["I18n"]): if locale not in self.locales: if n == 1: return singular - return plural if plural else singular + return plural or singular translator = self.locales[locale] @@ -116,8 +125,17 @@ class I18n(ContextInstanceMixin["I18n"]): return translator.ngettext(singular, plural, n) def lazy_gettext( - self, singular: str, plural: Optional[str] = None, n: int = 1, locale: Optional[str] = None + self, + singular: str, + plural: str | None = None, + n: int = 1, + locale: str | None = None, ) -> LazyProxy: return LazyProxy( - self.gettext, singular=singular, plural=plural, n=n, locale=locale, enable_cache=False + self.gettext, + singular=singular, + plural=plural, + n=n, + locale=locale, + enable_cache=False, ) diff --git a/aiogram/utils/i18n/lazy_proxy.py b/aiogram/utils/i18n/lazy_proxy.py index 6852540d..3c861840 100644 --- a/aiogram/utils/i18n/lazy_proxy.py +++ b/aiogram/utils/i18n/lazy_proxy.py @@ -6,8 +6,9 @@ except ImportError: # pragma: no cover class LazyProxy: # type: ignore def __init__(self, func: Any, *args: Any, **kwargs: Any) -> None: - raise RuntimeError( + msg = ( "LazyProxy can be used only when Babel installed\n" "Just install Babel (`pip install Babel`) " "or aiogram with i18n support (`pip install aiogram[i18n]`)" ) + raise RuntimeError(msg) diff --git a/aiogram/utils/i18n/middleware.py b/aiogram/utils/i18n/middleware.py index 68be22bc..462f4db0 100644 --- a/aiogram/utils/i18n/middleware.py +++ b/aiogram/utils/i18n/middleware.py @@ -1,5 +1,7 @@ +from __future__ import annotations + from abc import ABC, abstractmethod -from typing import Any, Awaitable, Callable, Dict, Optional, Set +from typing import TYPE_CHECKING, Any try: from babel import Locale, UnknownLocaleError @@ -11,9 +13,13 @@ except ImportError: # pragma: no cover from aiogram import BaseMiddleware, Router -from aiogram.fsm.context import FSMContext -from aiogram.types import TelegramObject, User -from aiogram.utils.i18n.core import I18n + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + + from aiogram.fsm.context import FSMContext + from aiogram.types import TelegramObject, User + from aiogram.utils.i18n.core import I18n class I18nMiddleware(BaseMiddleware, ABC): @@ -24,7 +30,7 @@ class I18nMiddleware(BaseMiddleware, ABC): def __init__( self, i18n: I18n, - i18n_key: Optional[str] = "i18n", + i18n_key: str | None = "i18n", middleware_key: str = "i18n_middleware", ) -> None: """ @@ -39,7 +45,9 @@ class I18nMiddleware(BaseMiddleware, ABC): self.middleware_key = middleware_key def setup( - self: BaseMiddleware, router: Router, exclude: Optional[Set[str]] = None + self: BaseMiddleware, + router: Router, + exclude: set[str] | None = None, ) -> BaseMiddleware: """ Register middleware for all events in the Router @@ -59,9 +67,9 @@ class I18nMiddleware(BaseMiddleware, ABC): async def __call__( self, - handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], + handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]], event: TelegramObject, - data: Dict[str, Any], + data: dict[str, Any], ) -> Any: current_locale = await self.get_locale(event=event, data=data) or self.i18n.default_locale @@ -74,7 +82,7 @@ class I18nMiddleware(BaseMiddleware, ABC): return await handler(event, data) @abstractmethod - async def get_locale(self, event: TelegramObject, data: Dict[str, Any]) -> str: + async def get_locale(self, event: TelegramObject, data: dict[str, Any]) -> str: """ Detect current user locale based on event and context. @@ -84,7 +92,6 @@ class I18nMiddleware(BaseMiddleware, ABC): :param data: :return: """ - pass class SimpleI18nMiddleware(I18nMiddleware): @@ -97,27 +104,29 @@ class SimpleI18nMiddleware(I18nMiddleware): def __init__( self, i18n: I18n, - i18n_key: Optional[str] = "i18n", + i18n_key: str | None = "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( + msg = ( 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]`)" ) + raise RuntimeError(msg) - async def get_locale(self, event: TelegramObject, data: Dict[str, Any]) -> str: + async def get_locale(self, event: TelegramObject, data: dict[str, Any]) -> str: if Locale is None: # pragma: no cover - raise RuntimeError( + msg = ( 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]`)" ) + raise RuntimeError(msg) - event_from_user: Optional[User] = data.get("event_from_user", None) + event_from_user: User | None = data.get("event_from_user") if event_from_user is None or event_from_user.language_code is None: return self.i18n.default_locale try: @@ -139,13 +148,13 @@ class ConstI18nMiddleware(I18nMiddleware): self, locale: str, i18n: I18n, - i18n_key: Optional[str] = "i18n", + i18n_key: str | None = "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: + async def get_locale(self, event: TelegramObject, data: dict[str, Any]) -> str: return self.locale @@ -158,14 +167,14 @@ class FSMI18nMiddleware(SimpleI18nMiddleware): self, i18n: I18n, key: str = "locale", - i18n_key: Optional[str] = "i18n", + i18n_key: str | None = "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") + async def get_locale(self, event: TelegramObject, data: dict[str, Any]) -> str: + fsm_context: FSMContext | None = data.get("state") locale = None if fsm_context: fsm_data = await fsm_context.get_data() diff --git a/aiogram/utils/keyboard.py b/aiogram/utils/keyboard.py index 8caa02b5..582c481d 100644 --- a/aiogram/utils/keyboard.py +++ b/aiogram/utils/keyboard.py @@ -4,19 +4,7 @@ from abc import ABC from copy import deepcopy from itertools import chain from itertools import cycle as repeat_all -from typing import ( - TYPE_CHECKING, - Any, - Generator, - Generic, - Iterable, - List, - Optional, - Type, - TypeVar, - Union, - cast, -) +from typing import TYPE_CHECKING, Any, Generic, TypeVar, cast from aiogram.filters.callback_data import CallbackData from aiogram.types import ( @@ -34,11 +22,14 @@ from aiogram.types import ( WebAppInfo, ) +if TYPE_CHECKING: + from collections.abc import Generator, Iterable + ButtonType = TypeVar("ButtonType", InlineKeyboardButton, KeyboardButton) T = TypeVar("T") -class KeyboardBuilder(Generic[ButtonType], ABC): +class KeyboardBuilder(ABC, Generic[ButtonType]): """ Generic keyboard builder that helps to adjust your markup with defined shape of lines. @@ -50,16 +41,19 @@ class KeyboardBuilder(Generic[ButtonType], ABC): max_buttons: int = 0 def __init__( - self, button_type: Type[ButtonType], markup: Optional[List[List[ButtonType]]] = None + self, + button_type: type[ButtonType], + markup: list[list[ButtonType]] | None = None, ) -> None: if not issubclass(button_type, (InlineKeyboardButton, KeyboardButton)): - raise ValueError(f"Button type {button_type} are not allowed here") - self._button_type: Type[ButtonType] = button_type + msg = f"Button type {button_type} are not allowed here" + raise ValueError(msg) + self._button_type: type[ButtonType] = button_type if markup: self._validate_markup(markup) else: markup = [] - self._markup: List[List[ButtonType]] = markup + self._markup: list[list[ButtonType]] = markup @property def buttons(self) -> Generator[ButtonType, None, None]: @@ -79,9 +73,8 @@ class KeyboardBuilder(Generic[ButtonType], ABC): """ allowed = self._button_type if not isinstance(button, allowed): - raise ValueError( - f"{button!r} should be type {allowed.__name__!r} not {type(button).__name__!r}" - ) + msg = f"{button!r} should be type {allowed.__name__!r} not {type(button).__name__!r}" + raise ValueError(msg) return True def _validate_buttons(self, *buttons: ButtonType) -> bool: @@ -93,7 +86,7 @@ class KeyboardBuilder(Generic[ButtonType], ABC): """ return all(map(self._validate_button, buttons)) - def _validate_row(self, row: List[ButtonType]) -> bool: + def _validate_row(self, row: list[ButtonType]) -> bool: """ Check that row of buttons are correct Row can be only list of allowed button types and has length 0 <= n <= 8 @@ -102,16 +95,18 @@ class KeyboardBuilder(Generic[ButtonType], ABC): :return: """ if not isinstance(row, list): - raise ValueError( + msg = ( f"Row {row!r} should be type 'List[{self._button_type.__name__}]' " f"not type {type(row).__name__}" ) + raise ValueError(msg) if len(row) > self.max_width: - raise ValueError(f"Row {row!r} is too long (max width: {self.max_width})") + msg = f"Row {row!r} is too long (max width: {self.max_width})" + raise ValueError(msg) self._validate_buttons(*row) return True - def _validate_markup(self, markup: List[List[ButtonType]]) -> bool: + def _validate_markup(self, markup: list[list[ButtonType]]) -> bool: """ Check that passed markup has correct data structure Markup is list of lists of buttons @@ -121,15 +116,17 @@ class KeyboardBuilder(Generic[ButtonType], ABC): """ count = 0 if not isinstance(markup, list): - raise ValueError( + msg = ( f"Markup should be type 'List[List[{self._button_type.__name__}]]' " f"not type {type(markup).__name__!r}" ) + raise ValueError(msg) for row in markup: self._validate_row(row) count += len(row) if count > self.max_buttons: - raise ValueError(f"Too much buttons detected Max allowed count - {self.max_buttons}") + msg = f"Too much buttons detected Max allowed count - {self.max_buttons}" + raise ValueError(msg) return True def _validate_size(self, size: Any) -> int: @@ -140,14 +137,14 @@ class KeyboardBuilder(Generic[ButtonType], ABC): :return: """ if not isinstance(size, int): - raise ValueError("Only int sizes are allowed") + msg = "Only int sizes are allowed" + raise ValueError(msg) if size not in range(self.min_width, self.max_width + 1): - raise ValueError( - f"Row size {size} is not allowed, range: [{self.min_width}, {self.max_width}]" - ) + msg = f"Row size {size} is not allowed, range: [{self.min_width}, {self.max_width}]" + raise ValueError(msg) return size - def export(self) -> List[List[ButtonType]]: + def export(self) -> list[list[ButtonType]]: """ Export configured markup as list of lists of buttons @@ -161,7 +158,7 @@ class KeyboardBuilder(Generic[ButtonType], ABC): """ return deepcopy(self._markup) - def add(self, *buttons: ButtonType) -> "KeyboardBuilder[ButtonType]": + def add(self, *buttons: ButtonType) -> KeyboardBuilder[ButtonType]: """ Add one or many buttons to markup. @@ -189,9 +186,7 @@ class KeyboardBuilder(Generic[ButtonType], ABC): self._markup = markup return self - def row( - self, *buttons: ButtonType, width: Optional[int] = None - ) -> "KeyboardBuilder[ButtonType]": + def row(self, *buttons: ButtonType, width: int | None = None) -> KeyboardBuilder[ButtonType]: """ Add row to markup @@ -211,7 +206,7 @@ class KeyboardBuilder(Generic[ButtonType], ABC): ) return self - def adjust(self, *sizes: int, repeat: bool = False) -> "KeyboardBuilder[ButtonType]": + def adjust(self, *sizes: int, repeat: bool = False) -> KeyboardBuilder[ButtonType]: """ Adjust previously added buttons to specific row sizes. @@ -232,7 +227,7 @@ class KeyboardBuilder(Generic[ButtonType], ABC): size = next(sizes_iter) markup = [] - row: List[ButtonType] = [] + row: list[ButtonType] = [] for button in self.buttons: if len(row) >= size: markup.append(row) @@ -244,33 +239,35 @@ class KeyboardBuilder(Generic[ButtonType], ABC): self._markup = markup return self - def _button(self, **kwargs: Any) -> "KeyboardBuilder[ButtonType]": + def _button(self, **kwargs: Any) -> KeyboardBuilder[ButtonType]: """ Add button to markup :param kwargs: :return: """ - if isinstance(callback_data := kwargs.get("callback_data", None), CallbackData): + if isinstance(callback_data := kwargs.get("callback_data"), CallbackData): kwargs["callback_data"] = callback_data.pack() button = self._button_type(**kwargs) return self.add(button) - def as_markup(self, **kwargs: Any) -> Union[InlineKeyboardMarkup, ReplyKeyboardMarkup]: + def as_markup(self, **kwargs: Any) -> InlineKeyboardMarkup | ReplyKeyboardMarkup: if self._button_type is KeyboardButton: - keyboard = cast(List[List[KeyboardButton]], self.export()) # type: ignore + keyboard = cast(list[list[KeyboardButton]], self.export()) # type: ignore return ReplyKeyboardMarkup(keyboard=keyboard, **kwargs) - inline_keyboard = cast(List[List[InlineKeyboardButton]], self.export()) # type: ignore + inline_keyboard = cast(list[list[InlineKeyboardButton]], self.export()) # type: ignore return InlineKeyboardMarkup(inline_keyboard=inline_keyboard) - def attach(self, builder: "KeyboardBuilder[ButtonType]") -> "KeyboardBuilder[ButtonType]": + def attach(self, builder: KeyboardBuilder[ButtonType]) -> KeyboardBuilder[ButtonType]: if not isinstance(builder, KeyboardBuilder): - raise ValueError(f"Only KeyboardBuilder can be attached, not {type(builder).__name__}") + msg = f"Only KeyboardBuilder can be attached, not {type(builder).__name__}" + raise ValueError(msg) if builder._button_type is not self._button_type: - raise ValueError( + msg = ( f"Only builders with same button type can be attached, " f"not {self._button_type.__name__} and {builder._button_type.__name__}" ) + raise ValueError(msg) self._markup.extend(builder.export()) return self @@ -306,18 +303,18 @@ class InlineKeyboardBuilder(KeyboardBuilder[InlineKeyboardButton]): self, *, text: str, - url: Optional[str] = None, - callback_data: Optional[Union[str, CallbackData]] = None, - web_app: Optional[WebAppInfo] = None, - login_url: Optional[LoginUrl] = None, - switch_inline_query: Optional[str] = None, - switch_inline_query_current_chat: Optional[str] = None, - switch_inline_query_chosen_chat: Optional[SwitchInlineQueryChosenChat] = None, - copy_text: Optional[CopyTextButton] = None, - callback_game: Optional[CallbackGame] = None, - pay: Optional[bool] = None, + url: str | None = None, + callback_data: str | CallbackData | None = None, + web_app: WebAppInfo | None = None, + login_url: LoginUrl | None = None, + switch_inline_query: str | None = None, + switch_inline_query_current_chat: str | None = None, + switch_inline_query_chosen_chat: SwitchInlineQueryChosenChat | None = None, + copy_text: CopyTextButton | None = None, + callback_game: CallbackGame | None = None, + pay: bool | None = None, **kwargs: Any, - ) -> "InlineKeyboardBuilder": + ) -> InlineKeyboardBuilder: return cast( InlineKeyboardBuilder, self._button( @@ -340,10 +337,10 @@ class InlineKeyboardBuilder(KeyboardBuilder[InlineKeyboardButton]): """Construct an InlineKeyboardMarkup""" return cast(InlineKeyboardMarkup, super().as_markup(**kwargs)) - def __init__(self, markup: Optional[List[List[InlineKeyboardButton]]] = None) -> None: + def __init__(self, markup: list[list[InlineKeyboardButton]] | None = None) -> None: super().__init__(button_type=InlineKeyboardButton, markup=markup) - def copy(self: "InlineKeyboardBuilder") -> "InlineKeyboardBuilder": + def copy(self: InlineKeyboardBuilder) -> InlineKeyboardBuilder: """ Make full copy of current builder with markup @@ -353,8 +350,9 @@ class InlineKeyboardBuilder(KeyboardBuilder[InlineKeyboardButton]): @classmethod def from_markup( - cls: Type["InlineKeyboardBuilder"], markup: InlineKeyboardMarkup - ) -> "InlineKeyboardBuilder": + cls: type[InlineKeyboardBuilder], + markup: InlineKeyboardMarkup, + ) -> InlineKeyboardBuilder: """ Create builder from existing markup @@ -377,14 +375,14 @@ class ReplyKeyboardBuilder(KeyboardBuilder[KeyboardButton]): self, *, text: str, - request_users: Optional[KeyboardButtonRequestUsers] = None, - request_chat: Optional[KeyboardButtonRequestChat] = None, - request_contact: Optional[bool] = None, - request_location: Optional[bool] = None, - request_poll: Optional[KeyboardButtonPollType] = None, - web_app: Optional[WebAppInfo] = None, + request_users: KeyboardButtonRequestUsers | None = None, + request_chat: KeyboardButtonRequestChat | None = None, + request_contact: bool | None = None, + request_location: bool | None = None, + request_poll: KeyboardButtonPollType | None = None, + web_app: WebAppInfo | None = None, **kwargs: Any, - ) -> "ReplyKeyboardBuilder": + ) -> ReplyKeyboardBuilder: return cast( ReplyKeyboardBuilder, self._button( @@ -403,10 +401,10 @@ class ReplyKeyboardBuilder(KeyboardBuilder[KeyboardButton]): """Construct a ReplyKeyboardMarkup""" return cast(ReplyKeyboardMarkup, super().as_markup(**kwargs)) - def __init__(self, markup: Optional[List[List[KeyboardButton]]] = None) -> None: + def __init__(self, markup: list[list[KeyboardButton]] | None = None) -> None: super().__init__(button_type=KeyboardButton, markup=markup) - def copy(self: "ReplyKeyboardBuilder") -> "ReplyKeyboardBuilder": + def copy(self: ReplyKeyboardBuilder) -> ReplyKeyboardBuilder: """ Make full copy of current builder with markup @@ -415,7 +413,7 @@ class ReplyKeyboardBuilder(KeyboardBuilder[KeyboardButton]): return ReplyKeyboardBuilder(markup=self.export()) @classmethod - def from_markup(cls, markup: ReplyKeyboardMarkup) -> "ReplyKeyboardBuilder": + def from_markup(cls, markup: ReplyKeyboardMarkup) -> ReplyKeyboardBuilder: """ Create builder from existing markup diff --git a/aiogram/utils/link.py b/aiogram/utils/link.py index 051247fa..b6ce70e0 100644 --- a/aiogram/utils/link.py +++ b/aiogram/utils/link.py @@ -7,7 +7,7 @@ BRANCH = "dev-3.x" BASE_PAGE_URL = f"{BASE_DOCS_URL}/en/{BRANCH}/" -def _format_url(url: str, *path: str, fragment_: Optional[str] = None, **query: Any) -> str: +def _format_url(url: str, *path: str, fragment_: str | None = None, **query: Any) -> str: url = urljoin(url, "/".join(path), allow_fragments=True) if query: url += "?" + urlencode(query) @@ -16,7 +16,7 @@ def _format_url(url: str, *path: str, fragment_: Optional[str] = None, **query: return url -def docs_url(*path: str, fragment_: Optional[str] = None, **query: Any) -> str: +def docs_url(*path: str, fragment_: str | None = None, **query: Any) -> str: return _format_url(BASE_PAGE_URL, *path, fragment_=fragment_, **query) @@ -30,7 +30,7 @@ def create_telegram_link(*path: str, **kwargs: Any) -> str: def create_channel_bot_link( username: str, - parameter: Optional[str] = None, + parameter: str | None = None, change_info: bool = False, post_messages: bool = False, edit_messages: bool = False, diff --git a/aiogram/utils/magic_filter.py b/aiogram/utils/magic_filter.py index 94c92079..563492e7 100644 --- a/aiogram/utils/magic_filter.py +++ b/aiogram/utils/magic_filter.py @@ -1,4 +1,5 @@ -from typing import Any, Iterable +from collections.abc import Iterable +from typing import Any from magic_filter import MagicFilter as _MagicFilter from magic_filter import MagicT as _MagicT diff --git a/aiogram/utils/markdown.py b/aiogram/utils/markdown.py index 52c6e1d9..290cfddb 100644 --- a/aiogram/utils/markdown.py +++ b/aiogram/utils/markdown.py @@ -137,7 +137,7 @@ def strikethrough(*content: Any, sep: str = " ") -> str: :return: """ return markdown_decoration.strikethrough( - value=markdown_decoration.quote(_join(*content, sep=sep)) + value=markdown_decoration.quote(_join(*content, sep=sep)), ) @@ -183,7 +183,7 @@ def blockquote(*content: Any, sep: str = "\n") -> str: :return: """ return markdown_decoration.blockquote( - value=markdown_decoration.quote(_join(*content, sep=sep)) + value=markdown_decoration.quote(_join(*content, sep=sep)), ) diff --git a/aiogram/utils/media_group.py b/aiogram/utils/media_group.py index ff985258..8a7eb53a 100644 --- a/aiogram/utils/media_group.py +++ b/aiogram/utils/media_group.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, List, Literal, Optional, Union, overload +from typing import Any, Literal, overload from aiogram.enums import InputMediaType from aiogram.types import ( @@ -12,12 +12,7 @@ from aiogram.types import ( MessageEntity, ) -MediaType = Union[ - InputMediaAudio, - InputMediaPhoto, - InputMediaVideo, - InputMediaDocument, -] +MediaType = InputMediaAudio | InputMediaPhoto | InputMediaVideo | InputMediaDocument MAX_MEDIA_GROUP_SIZE = 10 @@ -27,9 +22,9 @@ class MediaGroupBuilder: def __init__( self, - media: Optional[List[MediaType]] = None, - caption: Optional[str] = None, - caption_entities: Optional[List[MessageEntity]] = None, + media: list[MediaType] | None = None, + caption: str | None = None, + caption_entities: list[MessageEntity] | None = None, ) -> None: """ Helper class for building media groups. @@ -39,7 +34,7 @@ class MediaGroupBuilder: :param caption_entities: List of special entities in the caption, like usernames, URLs, etc. (optional) """ - self._media: List[MediaType] = [] + self._media: list[MediaType] = [] self.caption = caption self.caption_entities = caption_entities @@ -47,14 +42,16 @@ class MediaGroupBuilder: def _add(self, media: MediaType) -> None: if not isinstance(media, InputMedia): - raise ValueError("Media must be instance of InputMedia") + msg = "Media must be instance of InputMedia" + raise ValueError(msg) if len(self._media) >= MAX_MEDIA_GROUP_SIZE: - raise ValueError("Media group can't contain more than 10 elements") + msg = "Media group can't contain more than 10 elements" + raise ValueError(msg) self._media.append(media) - def _extend(self, media: List[MediaType]) -> None: + def _extend(self, media: list[MediaType]) -> None: for m in media: self._add(m) @@ -63,13 +60,13 @@ class MediaGroupBuilder: self, *, type: Literal[InputMediaType.AUDIO], - media: Union[str, InputFile], - caption: Optional[str] = None, - parse_mode: Optional[str] = UNSET_PARSE_MODE, - caption_entities: Optional[List[MessageEntity]] = None, - duration: Optional[int] = None, - performer: Optional[str] = None, - title: Optional[str] = None, + media: str | InputFile, + caption: str | None = None, + parse_mode: str | None = UNSET_PARSE_MODE, + caption_entities: list[MessageEntity] | None = None, + duration: int | None = None, + performer: str | None = None, + title: str | None = None, **kwargs: Any, ) -> None: pass @@ -79,11 +76,11 @@ class MediaGroupBuilder: self, *, type: Literal[InputMediaType.PHOTO], - media: Union[str, InputFile], - caption: Optional[str] = None, - parse_mode: Optional[str] = UNSET_PARSE_MODE, - caption_entities: Optional[List[MessageEntity]] = None, - has_spoiler: Optional[bool] = None, + media: str | InputFile, + caption: str | None = None, + parse_mode: str | None = UNSET_PARSE_MODE, + caption_entities: list[MessageEntity] | None = None, + has_spoiler: bool | None = None, **kwargs: Any, ) -> None: pass @@ -93,16 +90,16 @@ class MediaGroupBuilder: self, *, type: Literal[InputMediaType.VIDEO], - media: Union[str, InputFile], - thumbnail: Optional[Union[InputFile, str]] = None, - caption: Optional[str] = None, - parse_mode: Optional[str] = UNSET_PARSE_MODE, - caption_entities: Optional[List[MessageEntity]] = None, - width: Optional[int] = None, - height: Optional[int] = None, - duration: Optional[int] = None, - supports_streaming: Optional[bool] = None, - has_spoiler: Optional[bool] = None, + media: str | InputFile, + thumbnail: InputFile | str | None = None, + caption: str | None = None, + parse_mode: str | None = UNSET_PARSE_MODE, + caption_entities: list[MessageEntity] | None = None, + width: int | None = None, + height: int | None = None, + duration: int | None = None, + supports_streaming: bool | None = None, + has_spoiler: bool | None = None, **kwargs: Any, ) -> None: pass @@ -112,12 +109,12 @@ class MediaGroupBuilder: self, *, type: Literal[InputMediaType.DOCUMENT], - media: Union[str, InputFile], - thumbnail: Optional[Union[InputFile, str]] = None, - caption: Optional[str] = None, - parse_mode: Optional[str] = UNSET_PARSE_MODE, - caption_entities: Optional[List[MessageEntity]] = None, - disable_content_type_detection: Optional[bool] = None, + media: str | InputFile, + thumbnail: InputFile | str | None = None, + caption: str | None = None, + parse_mode: str | None = UNSET_PARSE_MODE, + caption_entities: list[MessageEntity] | None = None, + disable_content_type_detection: bool | None = None, **kwargs: Any, ) -> None: pass @@ -140,18 +137,19 @@ class MediaGroupBuilder: elif type_ == InputMediaType.DOCUMENT: self.add_document(**kwargs) else: - raise ValueError(f"Unknown media type: {type_!r}") + msg = f"Unknown media type: {type_!r}" + raise ValueError(msg) def add_audio( self, - media: Union[str, InputFile], - thumbnail: Optional[InputFile] = None, - caption: Optional[str] = None, - parse_mode: Optional[str] = UNSET_PARSE_MODE, - caption_entities: Optional[List[MessageEntity]] = None, - duration: Optional[int] = None, - performer: Optional[str] = None, - title: Optional[str] = None, + media: str | InputFile, + thumbnail: InputFile | None = None, + caption: str | None = None, + parse_mode: str | None = UNSET_PARSE_MODE, + caption_entities: list[MessageEntity] | None = None, + duration: int | None = None, + performer: str | None = None, + title: str | None = None, **kwargs: Any, ) -> None: """ @@ -189,16 +187,16 @@ class MediaGroupBuilder: performer=performer, title=title, **kwargs, - ) + ), ) def add_photo( self, - media: Union[str, InputFile], - caption: Optional[str] = None, - parse_mode: Optional[str] = UNSET_PARSE_MODE, - caption_entities: Optional[List[MessageEntity]] = None, - has_spoiler: Optional[bool] = None, + media: str | InputFile, + caption: str | None = None, + parse_mode: str | None = UNSET_PARSE_MODE, + caption_entities: list[MessageEntity] | None = None, + has_spoiler: bool | None = None, **kwargs: Any, ) -> None: """ @@ -228,21 +226,21 @@ class MediaGroupBuilder: caption_entities=caption_entities, has_spoiler=has_spoiler, **kwargs, - ) + ), ) def add_video( self, - media: Union[str, InputFile], - thumbnail: Optional[InputFile] = None, - caption: Optional[str] = None, - parse_mode: Optional[str] = UNSET_PARSE_MODE, - caption_entities: Optional[List[MessageEntity]] = None, - width: Optional[int] = None, - height: Optional[int] = None, - duration: Optional[int] = None, - supports_streaming: Optional[bool] = None, - has_spoiler: Optional[bool] = None, + media: str | InputFile, + thumbnail: InputFile | None = None, + caption: str | None = None, + parse_mode: str | None = UNSET_PARSE_MODE, + caption_entities: list[MessageEntity] | None = None, + width: int | None = None, + height: int | None = None, + duration: int | None = None, + supports_streaming: bool | None = None, + has_spoiler: bool | None = None, **kwargs: Any, ) -> None: """ @@ -290,17 +288,17 @@ class MediaGroupBuilder: supports_streaming=supports_streaming, has_spoiler=has_spoiler, **kwargs, - ) + ), ) def add_document( self, - media: Union[str, InputFile], - thumbnail: Optional[InputFile] = None, - caption: Optional[str] = None, - parse_mode: Optional[str] = UNSET_PARSE_MODE, - caption_entities: Optional[List[MessageEntity]] = None, - disable_content_type_detection: Optional[bool] = None, + media: str | InputFile, + thumbnail: InputFile | None = None, + caption: str | None = None, + parse_mode: str | None = UNSET_PARSE_MODE, + caption_entities: list[MessageEntity] | None = None, + disable_content_type_detection: bool | None = None, **kwargs: Any, ) -> None: """ @@ -342,10 +340,10 @@ class MediaGroupBuilder: caption_entities=caption_entities, disable_content_type_detection=disable_content_type_detection, **kwargs, - ) + ), ) - def build(self) -> List[MediaType]: + def build(self) -> list[MediaType]: """ Builds a list of media objects for a media group. @@ -353,7 +351,7 @@ class MediaGroupBuilder: :return: List of media objects. """ - update_first_media: Dict[str, Any] = {"caption": self.caption} + update_first_media: dict[str, Any] = {"caption": self.caption} if self.caption_entities is not None: update_first_media["caption_entities"] = self.caption_entities update_first_media["parse_mode"] = None diff --git a/aiogram/utils/mixins.py b/aiogram/utils/mixins.py index 86b3ed84..15d94cd9 100644 --- a/aiogram/utils/mixins.py +++ b/aiogram/utils/mixins.py @@ -1,18 +1,18 @@ from __future__ import annotations import contextvars -from typing import TYPE_CHECKING, Any, Dict, Generic, Optional, TypeVar, cast, overload +from typing import TYPE_CHECKING, Any, Generic, TypeVar, cast, overload if TYPE_CHECKING: - from typing_extensions import Literal + from typing import Literal __all__ = ("ContextInstanceMixin", "DataMixin") class DataMixin: @property - def data(self) -> Dict[str, Any]: - data: Optional[Dict[str, Any]] = getattr(self, "_data", None) + def data(self) -> dict[str, Any]: + data: dict[str, Any] | None = getattr(self, "_data", None) if data is None: data = {} setattr(self, "_data", data) @@ -30,7 +30,7 @@ class DataMixin: def __contains__(self, key: str) -> bool: return key in self.data - def get(self, key: str, default: Optional[Any] = None) -> Optional[Any]: + def get(self, key: str, default: Any | None = None) -> Any | None: return self.data.get(key, default) @@ -44,36 +44,40 @@ class ContextInstanceMixin(Generic[ContextInstance]): super().__init_subclass__() cls.__context_instance = contextvars.ContextVar(f"instance_{cls.__name__}") - @overload # noqa: F811 + @overload @classmethod - def get_current(cls) -> Optional[ContextInstance]: # pragma: no cover # noqa: F811 + def get_current(cls) -> ContextInstance | None: # pragma: no cover ... - @overload # noqa: F811 + @overload @classmethod - def get_current( # noqa: F811 - cls, no_error: Literal[True] - ) -> Optional[ContextInstance]: # pragma: no cover # noqa: F811 + def get_current( + cls, + no_error: Literal[True], + ) -> ContextInstance | None: # pragma: no cover ... - @overload # noqa: F811 + @overload @classmethod - def get_current( # noqa: F811 - cls, no_error: Literal[False] - ) -> ContextInstance: # pragma: no cover # noqa: F811 + def get_current( + cls, + no_error: Literal[False], + ) -> ContextInstance: # pragma: no cover ... - @classmethod # noqa: F811 - def get_current( # noqa: F811 - cls, no_error: bool = True - ) -> Optional[ContextInstance]: # pragma: no cover # noqa: F811 + @classmethod + def get_current( + cls, + no_error: bool = True, + ) -> ContextInstance | None: # pragma: no cover # on mypy 0.770 I catch that contextvars.ContextVar always contextvars.ContextVar[Any] cls.__context_instance = cast( - contextvars.ContextVar[ContextInstance], cls.__context_instance + contextvars.ContextVar[ContextInstance], + cls.__context_instance, ) try: - current: Optional[ContextInstance] = cls.__context_instance.get() + current: ContextInstance | None = cls.__context_instance.get() except LookupError: if no_error: current = None @@ -85,9 +89,8 @@ class ContextInstanceMixin(Generic[ContextInstance]): @classmethod def set_current(cls, value: ContextInstance) -> contextvars.Token[ContextInstance]: if not isinstance(value, cls): - raise TypeError( - f"Value should be instance of {cls.__name__!r} not {type(value).__name__!r}" - ) + msg = f"Value should be instance of {cls.__name__!r} not {type(value).__name__!r}" + raise TypeError(msg) return cls.__context_instance.set(value) @classmethod diff --git a/aiogram/utils/mypy_hacks.py b/aiogram/utils/mypy_hacks.py index ea47a9dc..2041f6a6 100644 --- a/aiogram/utils/mypy_hacks.py +++ b/aiogram/utils/mypy_hacks.py @@ -1,5 +1,10 @@ +from __future__ import annotations + import functools -from typing import Callable, TypeVar +from typing import TYPE_CHECKING, TypeVar + +if TYPE_CHECKING: + from collections.abc import Callable T = TypeVar("T") diff --git a/aiogram/utils/payload.py b/aiogram/utils/payload.py index dbdba653..057ee76f 100644 --- a/aiogram/utils/payload.py +++ b/aiogram/utils/payload.py @@ -61,13 +61,18 @@ Encoding and decoding with your own methods: """ +from __future__ import annotations + from base64 import urlsafe_b64decode, urlsafe_b64encode -from typing import Callable, Optional +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Callable def encode_payload( payload: str, - encoder: Optional[Callable[[bytes], bytes]] = None, + encoder: Callable[[bytes], bytes] | None = None, ) -> str: """Encode payload with encoder. @@ -85,7 +90,7 @@ def encode_payload( def decode_payload( payload: str, - decoder: Optional[Callable[[bytes], bytes]] = None, + decoder: Callable[[bytes], bytes] | None = None, ) -> str: """Decode URL-safe base64url payload with decoder.""" original_payload = _decode_b64(payload) diff --git a/aiogram/utils/serialization.py b/aiogram/utils/serialization.py index cc6ef8aa..5f69d6b2 100644 --- a/aiogram/utils/serialization.py +++ b/aiogram/utils/serialization.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Any, Dict, Optional +from typing import Any from pydantic import BaseModel @@ -9,7 +9,7 @@ from aiogram.methods import TelegramMethod from aiogram.types import InputFile -def _get_fake_bot(default: Optional[DefaultBotProperties] = None) -> Bot: +def _get_fake_bot(default: DefaultBotProperties | None = None) -> Bot: if default is None: default = DefaultBotProperties() return Bot(token="42:Fake", default=default) @@ -28,12 +28,12 @@ class DeserializedTelegramObject: """ data: Any - files: Dict[str, InputFile] + files: dict[str, InputFile] def deserialize_telegram_object( obj: Any, - default: Optional[DefaultBotProperties] = None, + default: DefaultBotProperties | None = None, include_api_method_name: bool = True, ) -> DeserializedTelegramObject: """ @@ -55,7 +55,7 @@ def deserialize_telegram_object( # Fake bot is needed to exclude global defaults from the object. fake_bot = _get_fake_bot(default=default) - files: Dict[str, InputFile] = {} + files: dict[str, InputFile] = {} prepared = fake_bot.session.prepare_value( obj, bot=fake_bot, @@ -70,7 +70,7 @@ def deserialize_telegram_object( def deserialize_telegram_object_to_python( obj: Any, - default: Optional[DefaultBotProperties] = None, + default: DefaultBotProperties | None = None, include_api_method_name: bool = True, ) -> Any: """ diff --git a/aiogram/utils/text_decorations.py b/aiogram/utils/text_decorations.py index 35f343d2..53cb70be 100644 --- a/aiogram/utils/text_decorations.py +++ b/aiogram/utils/text_decorations.py @@ -3,20 +3,23 @@ from __future__ import annotations import html import re from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, Generator, List, Optional, Pattern, cast +from typing import TYPE_CHECKING, cast from aiogram.enums import MessageEntityType if TYPE_CHECKING: + from collections.abc import Generator + from re import Pattern + from aiogram.types import MessageEntity __all__ = ( "HtmlDecoration", "MarkdownDecoration", "TextDecoration", + "add_surrogates", "html_decoration", "markdown_decoration", - "add_surrogates", "remove_surrogates", ) @@ -80,7 +83,7 @@ class TextDecoration(ABC): # API it will be here too return self.quote(text) - def unparse(self, text: str, entities: Optional[List[MessageEntity]] = None) -> str: + def unparse(self, text: str, entities: list[MessageEntity] | None = None) -> str: """ Unparse message entities @@ -92,15 +95,15 @@ class TextDecoration(ABC): self._unparse_entities( add_surrogates(text), sorted(entities, key=lambda item: item.offset) if entities else [], - ) + ), ) def _unparse_entities( self, text: bytes, - entities: List[MessageEntity], - offset: Optional[int] = None, - length: Optional[int] = None, + entities: list[MessageEntity], + offset: int | None = None, + length: int | None = None, ) -> Generator[str, None, None]: if offset is None: offset = 0 @@ -115,7 +118,7 @@ class TextDecoration(ABC): offset = entity.offset * 2 + entity.length * 2 sub_entities = list( - filter(lambda e: e.offset * 2 < (offset or 0), entities[index + 1 :]) + filter(lambda e: e.offset * 2 < (offset or 0), entities[index + 1 :]), ) yield self.apply_entity( entity, diff --git a/aiogram/utils/token.py b/aiogram/utils/token.py index c0738467..73821324 100644 --- a/aiogram/utils/token.py +++ b/aiogram/utils/token.py @@ -5,7 +5,7 @@ class TokenValidationError(Exception): pass -@lru_cache() +@lru_cache def validate_token(token: str) -> bool: """ Validate Telegram token @@ -14,9 +14,8 @@ def validate_token(token: str) -> bool: :return: """ if not isinstance(token, str): - raise TokenValidationError( - f"Token is invalid! It must be 'str' type instead of {type(token)} type." - ) + msg = f"Token is invalid! It must be 'str' type instead of {type(token)} type." + raise TokenValidationError(msg) if any(x.isspace() for x in token): message = "Token is invalid! It can't contains spaces." @@ -24,12 +23,13 @@ def validate_token(token: str) -> bool: left, sep, right = token.partition(":") if (not sep) or (not left.isdigit()) or (not right): - raise TokenValidationError("Token is invalid!") + msg = "Token is invalid!" + raise TokenValidationError(msg) return True -@lru_cache() +@lru_cache def extract_bot_id(token: str) -> int: """ Extract bot ID from Telegram token diff --git a/aiogram/utils/web_app.py b/aiogram/utils/web_app.py index 759f268c..2e4b6cb8 100644 --- a/aiogram/utils/web_app.py +++ b/aiogram/utils/web_app.py @@ -1,9 +1,10 @@ import hashlib import hmac import json +from collections.abc import Callable from datetime import datetime from operator import itemgetter -from typing import Any, Callable, Optional +from typing import Any from urllib.parse import parse_qsl from aiogram.types import TelegramObject @@ -25,9 +26,9 @@ class WebAppChat(TelegramObject): """Type of chat, can be either “group”, “supergroup” or “channel”""" title: str """Title of the chat""" - username: Optional[str] = None + username: str | None = None """Username of the chat""" - photo_url: Optional[str] = None + photo_url: str | None = None """URL of the chat’s photo. The photo can be in .jpeg or .svg formats. Only returned for Web Apps launched from the attachment menu.""" @@ -44,23 +45,23 @@ class WebAppUser(TelegramObject): and some programming languages may have difficulty/silent defects in interpreting it. It has at most 52 significant bits, so a 64-bit integer or a double-precision float type is safe for storing this identifier.""" - is_bot: Optional[bool] = None + is_bot: bool | None = None """True, if this user is a bot. Returns in the receiver field only.""" first_name: str """First name of the user or bot.""" - last_name: Optional[str] = None + last_name: str | None = None """Last name of the user or bot.""" - username: Optional[str] = None + username: str | None = None """Username of the user or bot.""" - language_code: Optional[str] = None + language_code: str | None = None """IETF language tag of the user's language. Returns in user field only.""" - is_premium: Optional[bool] = None + is_premium: bool | None = None """True, if this user is a Telegram Premium user.""" - added_to_attachment_menu: Optional[bool] = None + added_to_attachment_menu: bool | None = None """True, if this user added the bot to the attachment menu.""" - allows_write_to_pm: Optional[bool] = None + allows_write_to_pm: bool | None = None """True, if this user allowed the bot to message them.""" - photo_url: Optional[str] = None + photo_url: str | None = None """URL of the user’s profile photo. The photo can be in .jpeg or .svg formats. Only returned for Web Apps launched from the attachment menu.""" @@ -73,33 +74,33 @@ class WebAppInitData(TelegramObject): Source: https://core.telegram.org/bots/webapps#webappinitdata """ - query_id: Optional[str] = None + query_id: str | None = None """A unique identifier for the Web App session, required for sending messages via the answerWebAppQuery method.""" - user: Optional[WebAppUser] = None + user: WebAppUser | None = None """An object containing data about the current user.""" - receiver: Optional[WebAppUser] = None + receiver: WebAppUser | None = None """An object containing data about the chat partner of the current user in the chat where the bot was launched via the attachment menu. Returned only for Web Apps launched via the attachment menu.""" - chat: Optional[WebAppChat] = None + chat: WebAppChat | None = None """An object containing data about the chat where the bot was launched via the attachment menu. Returned for supergroups, channels, and group chats – only for Web Apps launched via the attachment menu.""" - chat_type: Optional[str] = None + chat_type: str | None = None """Type of the chat from which the Web App was opened. Can be either “sender” for a private chat with the user opening the link, “private”, “group”, “supergroup”, or “channel”. Returned only for Web Apps launched from direct links.""" - chat_instance: Optional[str] = None + chat_instance: str | None = None """Global identifier, uniquely corresponding to the chat from which the Web App was opened. Returned only for Web Apps launched from a direct link.""" - start_param: Optional[str] = None + start_param: str | None = None """The value of the startattach parameter, passed via link. Only returned for Web Apps when launched from the attachment menu via link. The value of the start_param parameter will also be passed in the GET-parameter tgWebAppStartParam, so the Web App can load the correct interface right away.""" - can_send_after: Optional[int] = None + can_send_after: int | None = None """Time in seconds, after which a message can be sent via the answerWebAppQuery method.""" auth_date: datetime """Unix time when the form was opened.""" @@ -132,7 +133,9 @@ def check_webapp_signature(token: str, init_data: str) -> bool: ) secret_key = hmac.new(key=b"WebAppData", msg=token.encode(), digestmod=hashlib.sha256) calculated_hash = hmac.new( - key=secret_key.digest(), msg=data_check_string.encode(), digestmod=hashlib.sha256 + key=secret_key.digest(), + msg=data_check_string.encode(), + digestmod=hashlib.sha256, ).hexdigest() return hmac.compare_digest(calculated_hash, hash_) @@ -180,4 +183,5 @@ def safe_parse_webapp_init_data( """ if check_webapp_signature(token, init_data): return parse_webapp_init_data(init_data, loads=loads) - raise ValueError("Invalid init data signature") + msg = "Invalid init data signature" + raise ValueError(msg) diff --git a/aiogram/utils/web_app_signature.py b/aiogram/utils/web_app_signature.py index 038026c3..6a5a934b 100644 --- a/aiogram/utils/web_app_signature.py +++ b/aiogram/utils/web_app_signature.py @@ -8,13 +8,15 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey from .web_app import WebAppInitData, parse_webapp_init_data PRODUCTION_PUBLIC_KEY = bytes.fromhex( - "e7bf03a2fa4602af4580703d88dda5bb59f32ed8b02a56c187fe7d34caed242d" + "e7bf03a2fa4602af4580703d88dda5bb59f32ed8b02a56c187fe7d34caed242d", ) TEST_PUBLIC_KEY = bytes.fromhex("40055058a4ee38156a06562e52eece92a771bcd8346a8c4615cb7376eddf72ec") def check_webapp_signature( - bot_id: int, init_data: str, public_key_bytes: bytes = PRODUCTION_PUBLIC_KEY + bot_id: int, + init_data: str, + public_key_bytes: bytes = PRODUCTION_PUBLIC_KEY, ) -> bool: """ Check incoming WebApp init data signature without bot token using only bot id. @@ -49,13 +51,16 @@ def check_webapp_signature( try: public_key.verify(signature, message) - return True except InvalidSignature: return False + else: + return True def safe_check_webapp_init_data_from_signature( - bot_id: int, init_data: str, public_key_bytes: bytes = PRODUCTION_PUBLIC_KEY + bot_id: int, + init_data: str, + public_key_bytes: bytes = PRODUCTION_PUBLIC_KEY, ) -> WebAppInitData: """ Validate raw WebApp init data using only bot id and return it as WebAppInitData object @@ -67,4 +72,5 @@ def safe_check_webapp_init_data_from_signature( """ if check_webapp_signature(bot_id, init_data, public_key_bytes): return parse_webapp_init_data(init_data) - raise ValueError("Invalid init data signature") + msg = "Invalid init data signature" + raise ValueError(msg) diff --git a/aiogram/webhook/aiohttp_server.py b/aiogram/webhook/aiohttp_server.py index 56c3ce0d..4e390a4d 100644 --- a/aiogram/webhook/aiohttp_server.py +++ b/aiogram/webhook/aiohttp_server.py @@ -2,7 +2,8 @@ import asyncio import secrets from abc import ABC, abstractmethod from asyncio import Transport -from typing import Any, Awaitable, Callable, Dict, Optional, Set, Tuple, cast +from collections.abc import Awaitable, Callable +from typing import TYPE_CHECKING, Any, cast from aiohttp import JsonPayload, MultipartWriter, Payload, web from aiohttp.typedefs import Handler @@ -12,9 +13,11 @@ from aiohttp.web_middlewares import middleware from aiogram import Bot, Dispatcher, loggers from aiogram.methods import TelegramMethod from aiogram.methods.base import TelegramType -from aiogram.types import InputFile from aiogram.webhook.security import IPFilter +if TYPE_CHECKING: + from aiogram.types import InputFile + def setup_application(app: Application, dispatcher: Dispatcher, /, **kwargs: Any) -> None: """ @@ -42,7 +45,7 @@ def setup_application(app: Application, dispatcher: Dispatcher, /, **kwargs: Any app.on_shutdown.append(on_shutdown) -def check_ip(ip_filter: IPFilter, request: web.Request) -> Tuple[str, bool]: +def check_ip(ip_filter: IPFilter, request: web.Request) -> tuple[str, bool]: # Try to resolve client IP over reverse proxy if forwarded_for := request.headers.get("X-Forwarded-For", ""): # Get the left-most ip when there is multiple ips @@ -98,7 +101,7 @@ class BaseRequestHandler(ABC): self.dispatcher = dispatcher self.handle_in_background = handle_in_background self.data = data - self._background_feed_update_tasks: Set[asyncio.Task[Any]] = set() + self._background_feed_update_tasks: set[asyncio.Task[Any]] = set() def register(self, app: Application, /, path: str, **kwargs: Any) -> None: """ @@ -128,13 +131,12 @@ class BaseRequestHandler(ABC): :param request: :return: Bot instance """ - pass @abstractmethod def verify_secret(self, telegram_secret_token: str, bot: Bot) -> bool: pass - async def _background_feed_update(self, bot: Bot, update: Dict[str, Any]) -> None: + async def _background_feed_update(self, bot: Bot, update: dict[str, Any]) -> None: result = await self.dispatcher.feed_raw_update(bot=bot, update=update, **self.data) if isinstance(result, TelegramMethod): await self.dispatcher.silent_call_request(bot=bot, result=result) @@ -142,15 +144,18 @@ class BaseRequestHandler(ABC): async def _handle_request_background(self, bot: Bot, request: web.Request) -> web.Response: feed_update_task = asyncio.create_task( self._background_feed_update( - bot=bot, update=await request.json(loads=bot.session.json_loads) - ) + bot=bot, + update=await request.json(loads=bot.session.json_loads), + ), ) self._background_feed_update_tasks.add(feed_update_task) feed_update_task.add_done_callback(self._background_feed_update_tasks.discard) return web.json_response({}, dumps=bot.session.json_dumps) def _build_response_writer( - self, bot: Bot, result: Optional[TelegramMethod[TelegramType]] + self, + bot: Bot, + result: TelegramMethod[TelegramType] | None, ) -> Payload: if not result: # we need to return something "empty" @@ -166,7 +171,7 @@ class BaseRequestHandler(ABC): payload = writer.append(result.__api_method__) payload.set_content_disposition("form-data", name="method") - files: Dict[str, InputFile] = {} + files: dict[str, InputFile] = {} for key, value in result.model_dump(warnings=False).items(): value = bot.session.prepare_value(value, bot=bot, files=files) if not value: @@ -185,7 +190,7 @@ class BaseRequestHandler(ABC): return writer async def _handle_request(self, bot: Bot, request: web.Request) -> web.Response: - result: Optional[TelegramMethod[Any]] = await self.dispatcher.feed_webhook_update( + result: TelegramMethod[Any] | None = await self.dispatcher.feed_webhook_update( bot, await request.json(loads=bot.session.json_loads), **self.data, @@ -209,7 +214,7 @@ class SimpleRequestHandler(BaseRequestHandler): dispatcher: Dispatcher, bot: Bot, handle_in_background: bool = True, - secret_token: Optional[str] = None, + secret_token: str | None = None, **data: Any, ) -> None: """ @@ -244,7 +249,7 @@ class TokenBasedRequestHandler(BaseRequestHandler): self, dispatcher: Dispatcher, handle_in_background: bool = True, - bot_settings: Optional[Dict[str, Any]] = None, + bot_settings: dict[str, Any] | None = None, **data: Any, ) -> None: """ @@ -265,7 +270,7 @@ class TokenBasedRequestHandler(BaseRequestHandler): if bot_settings is None: bot_settings = {} self.bot_settings = bot_settings - self.bots: Dict[str, Bot] = {} + self.bots: dict[str, Bot] = {} def verify_secret(self, telegram_secret_token: str, bot: Bot) -> bool: return True @@ -283,7 +288,8 @@ class TokenBasedRequestHandler(BaseRequestHandler): :param kwargs: """ if "{bot_token}" not in path: - raise ValueError("Path should contains '{bot_token}' substring") + msg = "Path should contains '{bot_token}' substring" + raise ValueError(msg) super().register(app, path=path, **kwargs) async def resolve_bot(self, request: web.Request) -> Bot: diff --git a/aiogram/webhook/security.py b/aiogram/webhook/security.py index 91d38204..71248e9f 100644 --- a/aiogram/webhook/security.py +++ b/aiogram/webhook/security.py @@ -1,5 +1,5 @@ +from collections.abc import Sequence from ipaddress import IPv4Address, IPv4Network -from typing import Optional, Sequence, Set, Union DEFAULT_TELEGRAM_NETWORKS = [ IPv4Network("149.154.160.0/20"), @@ -8,17 +8,17 @@ DEFAULT_TELEGRAM_NETWORKS = [ class IPFilter: - def __init__(self, ips: Optional[Sequence[Union[str, IPv4Network, IPv4Address]]] = None): - self._allowed_ips: Set[IPv4Address] = set() + def __init__(self, ips: Sequence[str | IPv4Network | IPv4Address] | None = None): + self._allowed_ips: set[IPv4Address] = set() if ips: self.allow(*ips) - def allow(self, *ips: Union[str, IPv4Network, IPv4Address]) -> None: + def allow(self, *ips: str | IPv4Network | IPv4Address) -> None: for ip in ips: self.allow_ip(ip) - def allow_ip(self, ip: Union[str, IPv4Network, IPv4Address]) -> None: + def allow_ip(self, ip: str | IPv4Network | IPv4Address) -> None: if isinstance(ip, str): ip = IPv4Network(ip) if "/" in ip else IPv4Address(ip) if isinstance(ip, IPv4Address): @@ -26,16 +26,17 @@ class IPFilter: elif isinstance(ip, IPv4Network): self._allowed_ips.update(ip.hosts()) else: - raise ValueError(f"Invalid type of ipaddress: {type(ip)} ('{ip}')") + msg = f"Invalid type of ipaddress: {type(ip)} ('{ip}')" + raise ValueError(msg) @classmethod def default(cls) -> "IPFilter": return cls(DEFAULT_TELEGRAM_NETWORKS) - def check(self, ip: Union[str, IPv4Address]) -> bool: + def check(self, ip: str | IPv4Address) -> bool: if not isinstance(ip, IPv4Address): ip = IPv4Address(ip) return ip in self._allowed_ips - def __contains__(self, item: Union[str, IPv4Address]) -> bool: + def __contains__(self, item: str | IPv4Address) -> bool: return self.check(item) diff --git a/examples/context_addition_from_filter.py b/examples/context_addition_from_filter.py index 73143417..50eda7d3 100644 --- a/examples/context_addition_from_filter.py +++ b/examples/context_addition_from_filter.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, Optional, Union +from typing import Any from aiogram import Router from aiogram.filters import Filter @@ -8,7 +8,7 @@ router = Router(name=__name__) class HelloFilter(Filter): - def __init__(self, name: Optional[str] = None) -> None: + def __init__(self, name: str | None = None) -> None: self.name = name async def __call__( @@ -16,7 +16,7 @@ class HelloFilter(Filter): message: Message, event_from_user: User, # Filters also can accept keyword parameters like in handlers - ) -> Union[bool, Dict[str, Any]]: + ) -> bool | dict[str, Any]: if message.text.casefold() == "hello": # Returning a dictionary that will update the context data return {"name": event_from_user.mention_html(name=self.name)} @@ -25,6 +25,7 @@ class HelloFilter(Filter): @router.message(HelloFilter()) async def my_handler( - message: Message, name: str # Now we can accept "name" as named parameter + message: Message, + name: str, # Now we can accept "name" as named parameter ) -> Any: - return message.answer("Hello, {name}!".format(name=name)) + return message.answer(f"Hello, {name}!") diff --git a/examples/error_handling.py b/examples/error_handling.py index fca83f82..ee02fbe1 100644 --- a/examples/error_handling.py +++ b/examples/error_handling.py @@ -75,11 +75,13 @@ async def handle_set_age(message: types.Message, command: CommandObject) -> None # To get the command arguments you can use `command.args` property. age = command.args if not age: - raise InvalidAge("No age provided. Please provide your age as a command argument.") + msg = "No age provided. Please provide your age as a command argument." + raise InvalidAge(msg) # If the age is invalid, raise an exception. if not age.isdigit(): - raise InvalidAge("Age should be a number") + msg = "Age should be a number" + raise InvalidAge(msg) # If the age is valid, send a message to the user. age = int(age) @@ -95,7 +97,8 @@ async def handle_set_name(message: types.Message, command: CommandObject) -> Non # To get the command arguments you can use `command.args` property. name = command.args if not name: - raise InvalidName("Invalid name. Please provide your name as a command argument.") + msg = "Invalid name. Please provide your name as a command argument." + raise InvalidName(msg) # If the name is valid, send a message to the user. await message.reply(text=f"Your name is {name}") diff --git a/examples/finite_state_machine.py b/examples/finite_state_machine.py index f371d2ca..27a51989 100644 --- a/examples/finite_state_machine.py +++ b/examples/finite_state_machine.py @@ -2,7 +2,7 @@ import asyncio import logging import sys from os import getenv -from typing import Any, Dict +from typing import Any from aiogram import Bot, Dispatcher, F, Router, html from aiogram.client.default import DefaultBotProperties @@ -66,7 +66,7 @@ async def process_name(message: Message, state: FSMContext) -> None: [ KeyboardButton(text="Yes"), KeyboardButton(text="No"), - ] + ], ], resize_keyboard=True, ), @@ -106,13 +106,13 @@ async def process_language(message: Message, state: FSMContext) -> None: if message.text.casefold() == "python": await message.reply( - "Python, you say? That's the language that makes my circuits light up! 😉" + "Python, you say? That's the language that makes my circuits light up! 😉", ) await show_summary(message=message, data=data) -async def show_summary(message: Message, data: Dict[str, Any], positive: bool = True) -> None: +async def show_summary(message: Message, data: dict[str, Any], positive: bool = True) -> None: name = data["name"] language = data.get("language", "") text = f"I'll keep in mind that, {html.quote(name)}, " @@ -124,7 +124,7 @@ async def show_summary(message: Message, data: Dict[str, Any], positive: bool = await message.answer(text=text, reply_markup=ReplyKeyboardRemove()) -async def main(): +async def main() -> None: # Initialize Bot instance with default bot properties which will be passed to all API calls bot = Bot(token=TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML)) diff --git a/examples/multibot.py b/examples/multibot.py index d5dbe5fc..e8de6794 100644 --- a/examples/multibot.py +++ b/examples/multibot.py @@ -1,7 +1,7 @@ import logging import sys from os import getenv -from typing import Any, Dict, Union +from typing import Any from aiohttp import web from finite_state_machine import form_router @@ -34,7 +34,7 @@ REDIS_DSN = "redis://127.0.0.1:6479" OTHER_BOTS_URL = f"{BASE_URL}{OTHER_BOTS_PATH}" -def is_bot_token(value: str) -> Union[bool, Dict[str, Any]]: +def is_bot_token(value: str) -> bool | dict[str, Any]: try: validate_token(value) except TokenValidationError: @@ -54,11 +54,11 @@ async def command_add_bot(message: Message, command: CommandObject, bot: Bot) -> return await message.answer(f"Bot @{bot_user.username} successful added") -async def on_startup(dispatcher: Dispatcher, bot: Bot): +async def on_startup(dispatcher: Dispatcher, bot: Bot) -> None: await bot.set_webhook(f"{BASE_URL}{MAIN_BOT_PATH}") -def main(): +def main() -> None: logging.basicConfig(level=logging.INFO, stream=sys.stdout) session = AiohttpSession() bot_settings = {"session": session, "parse_mode": ParseMode.HTML} diff --git a/examples/own_filter.py b/examples/own_filter.py index af87408a..df55b154 100644 --- a/examples/own_filter.py +++ b/examples/own_filter.py @@ -14,4 +14,4 @@ class MyFilter(Filter): @router.message(MyFilter("hello")) -async def my_handler(message: Message): ... +async def my_handler(message: Message) -> None: ... diff --git a/examples/quiz_scene.py b/examples/quiz_scene.py index b07888df..c3b7c087 100644 --- a/examples/quiz_scene.py +++ b/examples/quiz_scene.py @@ -263,7 +263,7 @@ quiz_router.message.register(QuizScene.as_handler(), Command("quiz")) @quiz_router.message(Command("start")) -async def command_start(message: Message, scenes: ScenesManager): +async def command_start(message: Message, scenes: ScenesManager) -> None: await scenes.close() await message.answer( "Hi! This is a quiz bot. To start the quiz, use the /quiz command.", @@ -271,7 +271,7 @@ async def command_start(message: Message, scenes: ScenesManager): ) -def create_dispatcher(): +def create_dispatcher() -> Dispatcher: # Event isolation is needed to correctly handle fast user responses dispatcher = Dispatcher( events_isolation=SimpleEventIsolation(), @@ -288,7 +288,7 @@ def create_dispatcher(): return dispatcher -async def main(): +async def main() -> None: dp = create_dispatcher() bot = Bot(token=TOKEN) await dp.start_polling(bot) diff --git a/examples/scene.py b/examples/scene.py index ab83bd44..45d6112c 100644 --- a/examples/scene.py +++ b/examples/scene.py @@ -34,11 +34,11 @@ class CancellableScene(Scene): """ @on.message(F.text.casefold() == BUTTON_CANCEL.text.casefold(), after=After.exit()) - async def handle_cancel(self, message: Message): + async def handle_cancel(self, message: Message) -> None: await message.answer("Cancelled.", reply_markup=ReplyKeyboardRemove()) @on.message(F.text.casefold() == BUTTON_BACK.text.casefold(), after=After.back()) - async def handle_back(self, message: Message): + async def handle_back(self, message: Message) -> None: await message.answer("Back.") @@ -48,7 +48,7 @@ class LanguageScene(CancellableScene, state="language"): """ @on.message.enter() - async def on_enter(self, message: Message): + async def on_enter(self, message: Message) -> None: await message.answer( "What language do you prefer?", reply_markup=ReplyKeyboardMarkup( @@ -58,14 +58,14 @@ class LanguageScene(CancellableScene, state="language"): ) @on.message(F.text.casefold() == "python", after=After.exit()) - async def process_python(self, message: Message): + async def process_python(self, message: Message) -> None: await message.answer( - "Python, you say? That's the language that makes my circuits light up! 😉" + "Python, you say? That's the language that makes my circuits light up! 😉", ) await self.input_language(message) @on.message(after=After.exit()) - async def input_language(self, message: Message): + async def input_language(self, message: Message) -> None: data: FSMData = await self.wizard.get_data() await self.show_results(message, language=message.text, **data) @@ -83,7 +83,7 @@ class LikeBotsScene(CancellableScene, state="like_bots"): """ @on.message.enter() - async def on_enter(self, message: Message): + async def on_enter(self, message: Message) -> None: await message.answer( "Did you like to write bots?", reply_markup=ReplyKeyboardMarkup( @@ -96,18 +96,18 @@ class LikeBotsScene(CancellableScene, state="like_bots"): ) @on.message(F.text.casefold() == "yes", after=After.goto(LanguageScene)) - async def process_like_write_bots(self, message: Message): + async def process_like_write_bots(self, message: Message) -> None: await message.reply("Cool! I'm too!") @on.message(F.text.casefold() == "no", after=After.exit()) - async def process_dont_like_write_bots(self, message: Message): + async def process_dont_like_write_bots(self, message: Message) -> None: await message.answer( "Not bad not terrible.\nSee you soon.", reply_markup=ReplyKeyboardRemove(), ) @on.message() - async def input_like_bots(self, message: Message): + async def input_like_bots(self, message: Message) -> None: await message.answer("I don't understand you :(") @@ -117,25 +117,25 @@ class NameScene(CancellableScene, state="name"): """ @on.message.enter() # Marker for handler that should be called when a user enters the scene. - async def on_enter(self, message: Message): + async def on_enter(self, message: Message) -> None: await message.answer( "Hi there! What's your name?", reply_markup=ReplyKeyboardMarkup(keyboard=[[BUTTON_CANCEL]], resize_keyboard=True), ) @on.callback_query.enter() # different types of updates that start the scene also supported. - async def on_enter_callback(self, callback_query: CallbackQuery): + async def on_enter_callback(self, callback_query: CallbackQuery) -> None: await callback_query.answer() await self.on_enter(callback_query.message) @on.message.leave() # Marker for handler that should be called when a user leaves the scene. - async def on_leave(self, message: Message): + async def on_leave(self, message: Message) -> None: data: FSMData = await self.wizard.get_data() name = data.get("name", "Anonymous") await message.answer(f"Nice to meet you, {html.quote(name)}!") @on.message(after=After.goto(LikeBotsScene)) - async def input_name(self, message: Message): + async def input_name(self, message: Message) -> None: await self.wizard.update_data(name=message.text) @@ -154,22 +154,22 @@ class DefaultScene( start_demo = on.message(F.text.casefold() == "demo", after=After.goto(NameScene)) @on.message(Command("demo")) - async def demo(self, message: Message): + async def demo(self, message: Message) -> None: await message.answer( "Demo started", reply_markup=InlineKeyboardMarkup( - inline_keyboard=[[InlineKeyboardButton(text="Go to form", callback_data="start")]] + inline_keyboard=[[InlineKeyboardButton(text="Go to form", callback_data="start")]], ), ) @on.callback_query(F.data == "start", after=After.goto(NameScene)) - async def demo_callback(self, callback_query: CallbackQuery): + async def demo_callback(self, callback_query: CallbackQuery) -> None: await callback_query.answer(cache_time=0) await callback_query.message.delete_reply_markup() @on.message.enter() # Mark that this handler should be called when a user enters the scene. @on.message() - async def default_handler(self, message: Message): + async def default_handler(self, message: Message) -> None: await message.answer( "Start demo?\nYou can also start demo via command /demo", reply_markup=ReplyKeyboardMarkup( diff --git a/examples/specify_updates.py b/examples/specify_updates.py index c25613a2..d38ee7d6 100644 --- a/examples/specify_updates.py +++ b/examples/specify_updates.py @@ -33,7 +33,7 @@ async def command_start_handler(message: Message) -> None: await message.answer( f"Hello, {hbold(message.from_user.full_name)}!", reply_markup=InlineKeyboardMarkup( - inline_keyboard=[[InlineKeyboardButton(text="Tap me, bro", callback_data="*")]] + inline_keyboard=[[InlineKeyboardButton(text="Tap me, bro", callback_data="*")]], ), ) @@ -43,7 +43,7 @@ async def chat_member_update(chat_member: ChatMemberUpdated, bot: Bot) -> None: await bot.send_message( chat_member.chat.id, f"Member {hcode(chat_member.from_user.id)} was changed " - + f"from {chat_member.old_chat_member.status} to {chat_member.new_chat_member.status}", + f"from {chat_member.old_chat_member.status} to {chat_member.new_chat_member.status}", ) diff --git a/examples/web_app/handlers.py b/examples/web_app/handlers.py index 843f4c63..428d8376 100644 --- a/examples/web_app/handlers.py +++ b/examples/web_app/handlers.py @@ -12,7 +12,7 @@ my_router = Router() @my_router.message(CommandStart()) -async def command_start(message: Message, bot: Bot, base_url: str): +async def command_start(message: Message, bot: Bot, base_url: str) -> None: await bot.set_chat_menu_button( chat_id=message.chat.id, menu_button=MenuButtonWebApp(text="Open Menu", web_app=WebAppInfo(url=f"{base_url}/demo")), @@ -21,28 +21,29 @@ async def command_start(message: Message, bot: Bot, base_url: str): @my_router.message(Command("webview")) -async def command_webview(message: Message, base_url: str): +async def command_webview(message: Message, base_url: str) -> None: await message.answer( "Good. Now you can try to send it via Webview", reply_markup=InlineKeyboardMarkup( inline_keyboard=[ [ InlineKeyboardButton( - text="Open Webview", web_app=WebAppInfo(url=f"{base_url}/demo") - ) - ] - ] + text="Open Webview", + web_app=WebAppInfo(url=f"{base_url}/demo"), + ), + ], + ], ), ) @my_router.message(~F.message.via_bot) # Echo to all messages except messages via bot -async def echo_all(message: Message, base_url: str): +async def echo_all(message: Message, base_url: str) -> None: await message.answer( "Test webview", reply_markup=InlineKeyboardMarkup( inline_keyboard=[ - [InlineKeyboardButton(text="Open", web_app=WebAppInfo(url=f"{base_url}/demo"))] - ] + [InlineKeyboardButton(text="Open", web_app=WebAppInfo(url=f"{base_url}/demo"))], + ], ), ) diff --git a/examples/web_app/main.py b/examples/web_app/main.py index 06b64566..fbbbcff1 100644 --- a/examples/web_app/main.py +++ b/examples/web_app/main.py @@ -18,14 +18,14 @@ TOKEN = getenv("BOT_TOKEN") APP_BASE_URL = getenv("APP_BASE_URL") -async def on_startup(bot: Bot, base_url: str): +async def on_startup(bot: Bot, base_url: str) -> None: await bot.set_webhook(f"{base_url}/webhook") await bot.set_chat_menu_button( - menu_button=MenuButtonWebApp(text="Open Menu", web_app=WebAppInfo(url=f"{base_url}/demo")) + menu_button=MenuButtonWebApp(text="Open Menu", web_app=WebAppInfo(url=f"{base_url}/demo")), ) -def main(): +def main() -> None: bot = Bot(token=TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML)) dispatcher = Dispatcher() dispatcher["base_url"] = APP_BASE_URL diff --git a/examples/web_app/routes.py b/examples/web_app/routes.py index b10b0554..1537c4eb 100644 --- a/examples/web_app/routes.py +++ b/examples/web_app/routes.py @@ -1,10 +1,11 @@ +from __future__ import annotations + from pathlib import Path +from typing import TYPE_CHECKING from aiohttp.web_fileresponse import FileResponse -from aiohttp.web_request import Request from aiohttp.web_response import json_response -from aiogram import Bot from aiogram.types import ( InlineKeyboardButton, InlineKeyboardMarkup, @@ -14,12 +15,18 @@ from aiogram.types import ( ) from aiogram.utils.web_app import check_webapp_signature, safe_parse_webapp_init_data +if TYPE_CHECKING: + from aiohttp.web_request import Request + from aiohttp.web_response import Response -async def demo_handler(request: Request): + from aiogram import Bot + + +async def demo_handler(request: Request) -> FileResponse: return FileResponse(Path(__file__).parent.resolve() / "demo.html") -async def check_data_handler(request: Request): +async def check_data_handler(request: Request) -> Response: bot: Bot = request.app["bot"] data = await request.post() @@ -28,7 +35,7 @@ async def check_data_handler(request: Request): return json_response({"ok": False, "err": "Unauthorized"}, status=401) -async def send_message_handler(request: Request): +async def send_message_handler(request: Request) -> Response: bot: Bot = request.app["bot"] data = await request.post() try: @@ -44,11 +51,11 @@ async def send_message_handler(request: Request): InlineKeyboardButton( text="Open", web_app=WebAppInfo( - url=str(request.url.with_scheme("https").with_path("demo")) + url=str(request.url.with_scheme("https").with_path("demo")), ), - ) - ] - ] + ), + ], + ], ) await bot.answer_web_app_query( web_app_query_id=web_app_init_data.query_id, diff --git a/examples/without_dispatcher.py b/examples/without_dispatcher.py index 87e1d8e6..a48bb6d3 100644 --- a/examples/without_dispatcher.py +++ b/examples/without_dispatcher.py @@ -15,7 +15,7 @@ def create_parser() -> ArgumentParser: return parser -async def main(): +async def main() -> None: parser = create_parser() ns = parser.parse_args() diff --git a/pyproject.toml b/pyproject.toml index 19db6402..0b25c687 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" name = "aiogram" description = 'Modern and fully asynchronous framework for Telegram Bot API' readme = "README.rst" -requires-python = ">=3.9" +requires-python = ">=3.10" license = "MIT" authors = [ { name = "Alex Root Junior", email = "jroot.junior@gmail.com" }, @@ -30,7 +30,6 @@ classifiers = [ "Typing :: Typed", "Intended Audience :: Developers", "Intended Audience :: System Administrators", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -60,7 +59,7 @@ fast = [ "aiodns>=3.0.0", ] redis = [ - "redis[hiredis]>=5.0.1,<5.3.0", + "redis[hiredis]>=6.2.0,<7", ] mongo = [ "motor>=3.3.2,<3.7.0", @@ -76,7 +75,7 @@ cli = [ "aiogram-cli>=1.1.0,<2.0.0", ] signature = [ - "cryptography>=43.0.0", + "cryptography>=46.0.0", ] test = [ "pytest~=7.4.2", @@ -88,8 +87,8 @@ test = [ "pytest-cov~=4.1.0", "pytest-aiohttp~=1.0.5", "aresponses~=2.1.6", - "pytz~=2023.3", - "pycryptodomex~=3.19.0", + "pytz~=2025.2", + "pycryptodomex~=3.23.0", ] docs = [ "Sphinx~=8.0.2", @@ -105,12 +104,12 @@ docs = [ "sphinxcontrib-towncrier~=0.4.0a0", ] dev = [ - "black~=24.4.2", - "isort~=5.13.2", - "ruff~=0.5.1", - "mypy~=1.10.0", + "black~=25.9.0", + "isort~=6.1.0", + "ruff~=0.13.3", + "mypy~=1.10.1", "toml~=0.10.2", - "pre-commit~=3.5", + "pre-commit~=4.3.0", "packaging~=24.1", "motor-types~=1.0.0b4", ] @@ -200,9 +199,8 @@ cov-mongo = [ ] view-cov = "google-chrome-stable reports/py{matrix:python}/coverage/index.html" - [[tool.hatch.envs.test.matrix]] -python = ["39", "310", "311", "312", "313"] +python = ["310", "311", "312", "313"] [tool.ruff] line-length = 99 @@ -219,7 +217,6 @@ exclude = [ "scripts", "*.egg-info", ] -target-version = "py39" [tool.ruff.lint] select = [ @@ -227,13 +224,13 @@ select = [ "C4", "E", "F", - "T10", - "T20", "Q", "RET", + "T10", + "T20", ] ignore = [ - "F401" + "F401", ] [tool.ruff.lint.isort] @@ -280,7 +277,7 @@ exclude_lines = [ [tool.mypy] plugins = "pydantic.mypy" -python_version = "3.9" +python_version = "3.10" show_error_codes = true show_error_context = true pretty = true @@ -315,7 +312,7 @@ disallow_untyped_defs = true [tool.black] line-length = 99 -target-version = ['py39', 'py310', 'py311', 'py312', 'py313'] +target-version = ['py310', 'py311', 'py312', 'py313'] exclude = ''' ( \.eggs diff --git a/tests/docker-compose.yml b/tests/docker-compose.yml index 895908ba..fbefdaba 100644 --- a/tests/docker-compose.yml +++ b/tests/docker-compose.yml @@ -1,13 +1,11 @@ -version: "3.9" - services: redis: - image: redis:6-alpine + image: redis:8-alpine ports: - "${REDIS_PORT-6379}:6379" mongo: - image: mongo:7.0.6 + image: mongo:8.0.14 environment: MONGO_INITDB_ROOT_USERNAME: mongo MONGO_INITDB_ROOT_PASSWORD: mongo diff --git a/tests/test_utils/test_dataclass.py b/tests/test_utils/test_dataclass.py index 27da088d..d714362d 100644 --- a/tests/test_utils/test_dataclass.py +++ b/tests/test_utils/test_dataclass.py @@ -24,8 +24,6 @@ class TestDataclassKwargs: @pytest.mark.parametrize( "py_version,expected", [ - ((3, 9, 0), ALL_VERSIONS), - ((3, 9, 2), ALL_VERSIONS), ((3, 10, 2), PY_310), ((3, 11, 0), PY_311), ((4, 13, 0), LATEST_PY),