diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index cf4c446..67a71db 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -9,22 +9,52 @@ permissions: contents: read jobs: + release-checks: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up uv + uses: astral-sh/setup-uv@v4 + with: + python-version: "3.10" + enable-cache: true + + - name: Check lint + run: uv run ruff check src tests + + - name: Check formatting + run: uv run ruff format --check src tests + + - name: Run tests + run: uv run pytest + + - name: Build docs + run: uv run sphinx-build -b html docs /tmp/pymax-docs + release-build: runs-on: ubuntu-latest + needs: release-checks steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - name: Set up uv uses: astral-sh/setup-uv@v4 with: python-version: "3.10" + enable-cache: true - name: Build release distributions run: uv build - name: Check distributions - run: uvx twine check dist/* + run: uv run twine check dist/* - name: Upload distributions uses: actions/upload-artifact@v4 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..d4f2cba --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,14 @@ +repos: + - repo: local + hooks: + - id: ruff-check + name: ruff check + entry: uv run ruff check --fix + language: system + types_or: [python, pyi] + + - id: ruff-format + name: ruff format + entry: uv run ruff format + language: system + types_or: [python, pyi] diff --git a/README.md b/README.md index 1f0f9e2..173e0a0 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,9 @@ client.include_router(router) ```bash uv sync --all-groups +uv run pre-commit install +uv run pre-commit run --all-files +uv run pytest uv run python -c "import pymax; print(pymax.__all__)" uv run sphinx-build -b html docs docs/_build/html ``` diff --git a/docs/index.rst b/docs/index.rst index 842547c..3d1bd99 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -18,6 +18,12 @@ PyMax - асинхронная Python-библиотека для Max API. Он минимальный рабочий пример с авторизацией, обработчиком сообщений и запуском клиента. +.. toctree:: + :maxdepth: 1 + :caption: Новости + + release-2-1-0 + .. toctree:: :maxdepth: 2 :caption: Руководство diff --git a/docs/release-2-1-0.rst b/docs/release-2-1-0.rst new file mode 100644 index 0000000..b8dfbab --- /dev/null +++ b/docs/release-2-1-0.rst @@ -0,0 +1,43 @@ +PyMax 2.1.0 +=========== + +Изменения относительно ``2.0.1``. + +Добавлено +--------- + +* Заявки на вступление в группы и каналы: ``get_join_requests()``, + ``confirm_join_request()`` / + ``confirm_join_requests()`` и отклонить их через ``decline_join_request()`` / + ``decline_join_requests()``. +* Доменные типы ``Member`` и ``Presence`` для заявок. +* 2FA: ``check_2fa()`` и ``change_password()``. +* QR-вход: ``authorize_qr_login(qr_link)``. +* Web app-боты: ``get_bot_init_data(bot_id, chat_id, start_param=None)``. + +Исправлено +---------- + +* TCP-заголовок приведен к схеме Max: ``ver:1``, ``cmd:1``, ``seq:2``, + ``opcode:2``, ``len/cof:4``. ``seq`` больше не падает после ``255``. +* Upload фото, видео и файлов использует ``ExtraConfig.proxy`` для HTTP-отправки + на upload URL. +* ``MaxApiError.title`` и ``MaxApiError.localized_message`` могут быть ``None``. + +Изменилось +---------- + +* ``check_2fa()`` возвращает ``False``, если профиль еще не загружен или сервер + не прислал ``profile_options``. +* При заданном ``ExtraConfig.proxy`` upload-запросы тоже идут через proxy. + Proxy должен выдерживать большие HTTP POST-запросы. +* ``Capability`` разделен на ``ProfileOptions`` и ``TwoFactorAction``. +* ``ApiFacade`` получил ``bots``-сервис. + +Миграция +-------- + +* Код на ``Client`` и ``WebClient`` обычно менять не нужно. +* Импорт ``pymax.api.auth.enums.Capability`` замените на ``TwoFactorAction`` или + ``ProfileOptions``. +* Если код ждал строку в ``MaxApiError.title``, теперь нужно учитывать ``None``. diff --git a/pyproject.toml b/pyproject.toml index 97b15fd..cd70b33 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "maxapi-python" -version = "2.0.1" +version = "2.1.0" description = "Python wrapper для API мессенджера Max" readme = "README.md" requires-python = ">=3.10" @@ -89,24 +89,32 @@ dev = [ {include-group = "docs"}, {include-group = "test"}, "build>=1.2.0", + "pre-commit>=4.0.0", "twine>=5.0.0", "pyright>=1.1.390", - "black>=24.0.0", - "isort>=5.13.0", - "flake8>=7.0.0", + "ruff>=0.8.0", ] [tool.pyright] venv = ".venv" venvPath = "." -[tool.black] +[tool.ruff] line-length = 79 -target-version = ["py310"] +target-version = "py310" -[tool.flake8] -max-line-length = 79 -max-complexity = 10 +[tool.ruff.lint] +select = ["E", "F", "I"] +ignore = ["E501"] + +[tool.ruff.lint.per-file-ignores] +"src/pymax/**/__init__.py" = ["F401", "F403"] +"tests/**" = ["F401"] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +line-ending = "auto" [tool.mypy] python_version = "3.10" @@ -114,12 +122,6 @@ warn_return_any = true warn_unused_configs = true plugins = ["pydantic.mypy"] -[tool.isort] -profile = "black" -line_length = 79 -multi_line_output = 3 -include_trailing_comma = true - [tool.pytest.ini_options] asyncio_mode = "auto" testpaths = ["tests"] diff --git a/src/pymax/__init__.py b/src/pymax/__init__.py index 801a79c..6865d64 100644 --- a/src/pymax/__init__.py +++ b/src/pymax/__init__.py @@ -1,4 +1,4 @@ -__version__ = "2.0.1" +__version__ = "2.1.0" from .auth import ( diff --git a/src/pymax/api/auth/enums.py b/src/pymax/api/auth/enums.py index 4d2d7d8..52e473b 100644 --- a/src/pymax/api/auth/enums.py +++ b/src/pymax/api/auth/enums.py @@ -8,10 +8,21 @@ class AuthType(str, Enum): RESEND = "RESEND" -class Capability(int, Enum): - DEFAULT = 0 # В душе не чаю что это такое но при первой установке 2фа там 0 3 4 так что пусть будет дефолт +class ProfileOptions(int, Enum): + """Битовые/числовые признаки профиля, связанные с 2FA.""" + ESIA_VERIFIED_FLAG = 1 SECOND_FACTOR_PASSWORD_ENABLED = 2 SECOND_FACTOR_HAS_EMAIL = 3 SECOND_FACTOR_HAS_HINT = 4 + + +class TwoFactorAction(int, Enum): + """Действия 2FA, передаваемые в expectedCapabilities.""" + + SET_PASSWORD = 0 + UPDATE_PASSWORD = 1 + RESTORE_PASSWORD = 2 + HINT = 3 + EMAIL = 4 REMOVE_2FA = 5 diff --git a/src/pymax/api/auth/payloads.py b/src/pymax/api/auth/payloads.py index 9a7f459..f515f2a 100644 --- a/src/pymax/api/auth/payloads.py +++ b/src/pymax/api/auth/payloads.py @@ -1,10 +1,10 @@ -from pydantic import Field, field_serializer +from pydantic import Field from pymax.api.models import CamelModel from pymax.api.session.payloads import MobileUserAgentPayload from pymax.types.domain.sync import DEFAULT_CONFIG_HASH, ConfigHash, SyncState -from .enums import AuthType, Capability +from .enums import AuthType, TwoFactorAction class RequestCodePayload(CamelModel): @@ -115,7 +115,7 @@ class SetHintPayload(CamelModel): class SetTwoFactorPayload(CamelModel): - expected_capabilities: list[Capability] + expected_capabilities: list[TwoFactorAction] track_id: str password: str hint: str | None = None @@ -123,7 +123,11 @@ class SetTwoFactorPayload(CamelModel): class RemoveTwoFactorPayload(CamelModel): track_id: str - remove2fa: bool = True - expected_capabilities: list[Capability] = Field( - default_factory=lambda: [Capability.REMOVE_2FA] + remove2fa: bool = Field(default=True, alias="remove2fa") + expected_capabilities: list[TwoFactorAction] = Field( + default_factory=lambda: [TwoFactorAction.REMOVE_2FA] ) + + +class ApproveQrLoginPayload(CamelModel): + qr_link: str diff --git a/src/pymax/api/auth/service.py b/src/pymax/api/auth/service.py index 5b13147..8bee7b1 100644 --- a/src/pymax/api/auth/service.py +++ b/src/pymax/api/auth/service.py @@ -2,7 +2,11 @@ from typing import TYPE_CHECKING -from pymax.api.response import payload_item, payload_keys, require_payload_model +from pymax.api.response import ( + payload_item, + payload_keys, + require_payload_model, +) from pymax.api.session.enums import DeviceType from pymax.auth import EmailCodeProvider from pymax.auth.providers import ConsoleEmailCodeProvider @@ -17,8 +21,9 @@ ) from pymax.types.domain.login import LoginResponse -from .enums import Capability +from .enums import ProfileOptions, TwoFactorAction from .payloads import ( + ApproveQrLoginPayload, CheckPasswordChallengePayload, CheckQrPayload, ConfirmQrPayload, @@ -51,14 +56,18 @@ def __init__(self, app: App) -> None: async def request_code(self, phone: str) -> StartAuthResponse: logger.info("requesting sms code phone_set=%s", bool(phone)) frame = RequestCodePayload(phone=phone) - response = await self.app.invoke(Opcode.AUTH_REQUEST, frame.to_payload()) + response = await self.app.invoke( + Opcode.AUTH_REQUEST, frame.to_payload() + ) logger.debug( "sms code request accepted payload_keys=%s", payload_keys(response), ) return require_payload_model(response, StartAuthResponse) - async def send_code(self, token: str, verify_code: str) -> CheckCodeResponse: + async def send_code( + self, token: str, verify_code: str + ) -> CheckCodeResponse: logger.info( "sending sms code token_set=%s code_set=%s", bool(token), @@ -152,14 +161,18 @@ async def request_qr(self) -> RequestQrResponse: async def check_qr(self, track_id: str) -> CheckQrResponse: frame = CheckQrPayload(track_id=track_id) - response = await self.app.invoke(Opcode.GET_QR_STATUS, frame.to_payload()) + response = await self.app.invoke( + Opcode.GET_QR_STATUS, frame.to_payload() + ) return require_payload_model(response, CheckQrResponse) async def confirm_qr(self, track_id: str) -> CheckCodeResponse: frame = ConfirmQrPayload(track_id=track_id) - response = await self.app.invoke(Opcode.LOGIN_BY_QR, frame.to_payload()) + response = await self.app.invoke( + Opcode.LOGIN_BY_QR, frame.to_payload() + ) return require_payload_model(response, CheckCodeResponse) @@ -182,11 +195,15 @@ async def _get_track_id(self) -> str | None: logger.debug("creating auth track") frame = CreateAuthTrackPayload() - response = await self.app.invoke(Opcode.AUTH_CREATE_TRACK, frame.to_payload()) + response = await self.app.invoke( + Opcode.AUTH_CREATE_TRACK, frame.to_payload() + ) return payload_item(response, "trackId", str) - async def _set_email(self, track_id: str, email: str, provider: EmailCodeProvider) -> bool: + async def _set_email( + self, track_id: str, email: str, provider: EmailCodeProvider + ) -> bool: logger.info("setting 2fa email email_set=%s", bool(email)) frame = RequestEmailCodePayload( @@ -225,7 +242,9 @@ async def _set_password(self, track_id: str, password: str) -> bool: track_id=track_id, password=password, ) - await self.app.invoke(Opcode.AUTH_VALIDATE_PASSWORD, frame.to_payload()) + await self.app.invoke( + Opcode.AUTH_VALIDATE_PASSWORD, frame.to_payload() + ) return True @@ -263,13 +282,13 @@ async def set_2fa( await self._set_hint(track_id, str(hint)) has_hint = True - expected_capabilities = [Capability.DEFAULT] + expected_capabilities = [TwoFactorAction.SET_PASSWORD] if has_hint: - expected_capabilities.append(Capability.SECOND_FACTOR_HAS_HINT) + expected_capabilities.append(TwoFactorAction.HINT) if has_email: - expected_capabilities.append(Capability.SECOND_FACTOR_HAS_EMAIL) + expected_capabilities.append(TwoFactorAction.EMAIL) frame = SetTwoFactorPayload( track_id=track_id, @@ -311,3 +330,47 @@ async def remove_2fa(self, password: str) -> bool: await self.app.invoke(Opcode.AUTH_SET_2FA, frame.to_payload()) return True + + async def authorize_qr_login(self, qr_link: str) -> bool: + logger.info("approving qr login qr_link_set=%s", bool(qr_link)) + + frame = ApproveQrLoginPayload(qr_link=qr_link) + + await self.app.invoke(Opcode.AUTH_QR_APPROVE, frame.to_payload()) + + return True + + async def check_2fa(self) -> bool: + if not self.app.me or not self.app.me.profile_options: + return False + + return ( + ProfileOptions.SECOND_FACTOR_PASSWORD_ENABLED + in self.app.me.profile_options + ) + + async def change_password( + self, password_old: str, password_new: str + ) -> bool: + track_id = await self._get_track_id() + + if not track_id: + logger.error("missing track_id in auth create track response") + raise RuntimeError("Failed to create auth track") + + await self._check_2fa_password(track_id, password_old) + + await self._set_password(track_id, password_new) + + expected_capabilities = [TwoFactorAction.UPDATE_PASSWORD] + + frame = SetTwoFactorPayload( + track_id=track_id, + password=password_new, + hint=None, + expected_capabilities=expected_capabilities, + ) + + await self.app.invoke(Opcode.AUTH_SET_2FA, frame.to_payload()) + logger.info("2fa password set successfully") + return True diff --git a/src/pymax/api/bots/__init__.py b/src/pymax/api/bots/__init__.py new file mode 100644 index 0000000..ec2f130 --- /dev/null +++ b/src/pymax/api/bots/__init__.py @@ -0,0 +1 @@ +from .service import BotsService diff --git a/src/pymax/api/bots/payloads.py b/src/pymax/api/bots/payloads.py new file mode 100644 index 0000000..189f957 --- /dev/null +++ b/src/pymax/api/bots/payloads.py @@ -0,0 +1,7 @@ +from pymax.api.models import CamelModel + + +class RequestInitDataPayload(CamelModel): + bot_id: int + chat_id: int + start_param: str | None = None diff --git a/src/pymax/api/bots/service.py b/src/pymax/api/bots/service.py new file mode 100644 index 0000000..a672bc3 --- /dev/null +++ b/src/pymax/api/bots/service.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pymax.api.response import require_payload_model +from pymax.logging import get_logger +from pymax.protocol import Opcode +from pymax.types.domain import InitData + +from .payloads import RequestInitDataPayload + +if TYPE_CHECKING: + from pymax.app import App + + +logger = get_logger(__name__) + + +class BotsService: + def __init__(self, app: App) -> None: + self.app = app + + async def get_init_data( + self, + bot_id: int, + chat_id: int, + start_param: str | None = None, + ) -> InitData: + frame = RequestInitDataPayload( + bot_id=bot_id, chat_id=chat_id, start_param=start_param + ) + response = await self.app.invoke( + Opcode.WEB_APP_INIT_DATA, frame.to_payload() + ) + return require_payload_model(response, InitData) diff --git a/src/pymax/api/chats/enums.py b/src/pymax/api/chats/enums.py index 90e9a3c..4f61325 100644 --- a/src/pymax/api/chats/enums.py +++ b/src/pymax/api/chats/enums.py @@ -21,6 +21,7 @@ class ChatOption(str, Enum): class ChatPayloadKey(str, Enum): CHAT = "chat" CHATS = "chats" + MEMBERS = "members" class ChatLinkPrefix(str, Enum): diff --git a/src/pymax/api/chats/payloads.py b/src/pymax/api/chats/payloads.py index 02ec4f5..5aab501 100644 --- a/src/pymax/api/chats/payloads.py +++ b/src/pymax/api/chats/payloads.py @@ -101,3 +101,17 @@ class LeaveChatPayload(CamelModel): class FetchChatsPayload(CamelModel): marker: int + + +class FetchJoinRequests(CamelModel): + chat_id: int + type: str = "JOIN_REQUEST" # ENUM!!!!! + count: int = 100 + + +class JoinRequestActionPayload(CamelModel): + chat_id: int + user_ids: list[int] + type: str = "JOIN_REQUEST" # TODO: ENUMM!!! + show_history: bool | None = True + operation: ChatMemberOperation diff --git a/src/pymax/api/chats/service.py b/src/pymax/api/chats/service.py index 9780201..620489b 100644 --- a/src/pymax/api/chats/service.py +++ b/src/pymax/api/chats/service.py @@ -12,9 +12,9 @@ from pymax.exceptions import PyMaxError from pymax.logging import get_logger from pymax.protocol import Opcode -from pymax.types.domain import Chat, Message +from pymax.types.domain import Chat, Member, Message -from .enums import ChatLinkPrefix, ChatPayloadKey +from .enums import ChatLinkPrefix, ChatMemberOperation, ChatPayloadKey from .payloads import ( ChangeGroupProfilePayload, ChangeGroupSettingsOptions, @@ -23,9 +23,11 @@ CreateGroupMessage, CreateGroupPayload, FetchChatsPayload, + FetchJoinRequests, GetChatInfoPayload, InviteUsersPayload, JoinChatPayload, + JoinRequestActionPayload, LeaveChatPayload, LinkInfoPayload, RemoveUsersPayload, @@ -70,7 +72,9 @@ def _remove_cached_chat(self, chat_id: int) -> None: if self.app.chats is None: return - self.app.chats = [chat for chat in self.app.chats if chat.id != chat_id] + self.app.chats = [ + chat for chat in self.app.chats if chat.id != chat_id + ] @staticmethod def _process_chat_join_link(link: str) -> str | None: @@ -108,7 +112,9 @@ async def create_group( return None chat = self._cache_chat(chat) - message = require_payload_model(response, Message).bind(self.app.api.messages) + message = require_payload_model(response, Message).bind( + self.app.api.messages + ) return chat, message async def invite_users_to_group( @@ -139,7 +145,9 @@ async def invite_users_to_channel( user_ids: list[int], show_history: bool = True, ) -> Chat | None: - return await self.invite_users_to_group(chat_id, user_ids, show_history) + return await self.invite_users_to_group( + chat_id, user_ids, show_history + ) async def remove_users_from_group( self, @@ -183,7 +191,9 @@ async def change_group_settings( ), ) - response = await self.app.invoke(Opcode.CHAT_UPDATE, frame.to_payload()) + response = await self.app.invoke( + Opcode.CHAT_UPDATE, frame.to_payload() + ) chat = parse_payload_item_model(response, ChatPayloadKey.CHAT, Chat) if chat: self._cache_chat(chat) @@ -200,7 +210,9 @@ async def change_group_profile( description=description, ) - response = await self.app.invoke(Opcode.CHAT_UPDATE, frame.to_payload()) + response = await self.app.invoke( + Opcode.CHAT_UPDATE, frame.to_payload() + ) chat = parse_payload_item_model(response, ChatPayloadKey.CHAT, Chat) if chat: self._cache_chat(chat) @@ -230,7 +242,9 @@ async def resolve_group_by_link(self, link: str) -> Chat | None: async def rework_invite_link(self, chat_id: int) -> Chat: frame = ReworkInviteLinkPayload(chat_id=chat_id) - response = await self.app.invoke(Opcode.CHAT_UPDATE, frame.to_payload()) + response = await self.app.invoke( + Opcode.CHAT_UPDATE, frame.to_payload() + ) chat = require_payload_item_model(response, ChatPayloadKey.CHAT, Chat) return self._cache_chat(chat) @@ -240,12 +254,18 @@ async def get_chats(self, chat_ids: list[int]) -> list[Chat]: for chat_id in chat_ids if (chat := self._get_cached_chat(chat_id)) is not None } - missed_chat_ids = [chat_id for chat_id in chat_ids if chat_id not in cached] + missed_chat_ids = [ + chat_id for chat_id in chat_ids if chat_id not in cached + ] if missed_chat_ids: frame = GetChatInfoPayload(chat_ids=missed_chat_ids) - response = await self.app.invoke(Opcode.CHAT_INFO, frame.to_payload()) - for chat in parse_payload_list(response, ChatPayloadKey.CHATS, Chat): + response = await self.app.invoke( + Opcode.CHAT_INFO, frame.to_payload() + ) + for chat in parse_payload_list( + response, ChatPayloadKey.CHATS, Chat + ): chat = self._cache_chat(chat) cached[chat.id] = chat @@ -272,6 +292,86 @@ async def fetch_chats(self, marker: int | None = None) -> list[Chat]: chats = [ self._cache_chat(chat) - for chat in parse_payload_list(response, ChatPayloadKey.CHATS, Chat) + for chat in parse_payload_list( + response, ChatPayloadKey.CHATS, Chat + ) ] return chats + + async def get_join_requests( + self, chat_id: int, count: int = 100 + ) -> list[Member]: + frame = FetchJoinRequests(chat_id=chat_id, count=count) + + response = await self.app.invoke( + Opcode.CHAT_MEMBERS, frame.to_payload() + ) + + return parse_payload_list(response, ChatPayloadKey.MEMBERS, Member) + + async def confirm_join_requests( + self, + chat_id: int, + user_ids: list[int], + show_history: bool = True, + ) -> Chat | None: + frame = JoinRequestActionPayload( + chat_id=chat_id, + user_ids=user_ids, + show_history=show_history, + operation=ChatMemberOperation.ADD, + ) + + response = await self.app.invoke( + Opcode.CHAT_MEMBERS_UPDATE, frame.to_payload() + ) + + chat = parse_payload_item_model(response, ChatPayloadKey.CHAT, Chat) + if chat: + return self._cache_chat(chat) + + return None + + async def confirm_join_request( + self, + chat_id: int, + user_id: int, + show_history: bool = True, + ) -> Chat | None: + return await self.confirm_join_requests( + chat_id=chat_id, + user_ids=[user_id], + show_history=show_history, + ) + + async def decline_join_requests( + self, + chat_id: int, + user_ids: list[int], + ) -> Chat | None: + frame = JoinRequestActionPayload( + chat_id=chat_id, + user_ids=user_ids, + show_history=None, + operation=ChatMemberOperation.REMOVE, + ) + + response = await self.app.invoke( + Opcode.CHAT_MEMBERS_UPDATE, frame.to_payload() + ) + + chat = parse_payload_item_model(response, ChatPayloadKey.CHAT, Chat) + if chat: + return self._cache_chat(chat) + + return None + + async def decline_join_request( + self, + chat_id: int, + user_id: int, + ) -> Chat | None: + return await self.decline_join_requests( + chat_id=chat_id, + user_ids=[user_id], + ) diff --git a/src/pymax/api/facade.py b/src/pymax/api/facade.py index 7181a60..24f25cf 100644 --- a/src/pymax/api/facade.py +++ b/src/pymax/api/facade.py @@ -5,6 +5,7 @@ from pymax.logging import get_logger from .auth import AuthService +from .bots import BotsService from .chats import ChatService from .messages import MessageService from .self import SelfService @@ -25,6 +26,7 @@ def __init__(self, app: App) -> None: self.messages = MessageService(app) self.chats = ChatService(app) self.users = UserService(app) + self.bots = BotsService(app) self.account = SelfService(app) self.session = SessionService(app) self.auth = AuthService(app) diff --git a/src/pymax/api/messages/payloads.py b/src/pymax/api/messages/payloads.py index 86afb73..9aef2e0 100644 --- a/src/pymax/api/messages/payloads.py +++ b/src/pymax/api/messages/payloads.py @@ -3,7 +3,11 @@ from pydantic import Field from pymax.api.models import CamelModel -from pymax.api.uploads.payloads import AttachFilePayload, AttachPhotoPayload, VideoAttachPayload +from pymax.api.uploads.payloads import ( + AttachFilePayload, + AttachPhotoPayload, + VideoAttachPayload, +) from .enums import ItemType, ReadAction diff --git a/src/pymax/api/messages/service.py b/src/pymax/api/messages/service.py index 51eed24..1bc3a4d 100644 --- a/src/pymax/api/messages/service.py +++ b/src/pymax/api/messages/service.py @@ -9,7 +9,11 @@ payload_item, require_payload_model, ) -from pymax.api.uploads.payloads import AttachFilePayload, AttachPhotoPayload, VideoAttachPayload +from pymax.api.uploads.payloads import ( + AttachFilePayload, + AttachPhotoPayload, + VideoAttachPayload, +) from pymax.exceptions import UploadError from pymax.files import File, Photo, Video from pymax.formatting.markdown import Formatter @@ -65,13 +69,17 @@ def _next_cid(self) -> int: async def _upload_attachments( self, attachments: SendAttachments ) -> list[AttachPhotoPayload | VideoAttachPayload | AttachFilePayload]: - result: list[AttachPhotoPayload | VideoAttachPayload | AttachFilePayload] = [] + result: list[ + AttachPhotoPayload | VideoAttachPayload | AttachFilePayload + ] = [] if not attachments: return result for attachment in attachments: if isinstance(attachment, Photo): - upload_result = await self.app.api.uploads.upload_photo(attachment) + upload_result = await self.app.api.uploads.upload_photo( + attachment + ) if not upload_result: logger.error("Photo uploading failed") raise UploadError("Photo uploading failed") @@ -79,7 +87,9 @@ async def _upload_attachments( result.append(upload_result) elif isinstance(attachment, Video): - upload_result = await self.app.api.uploads.upload_video(attachment) + upload_result = await self.app.api.uploads.upload_video( + attachment + ) if not upload_result: logger.error("Video uploading failed") raise UploadError("Video uploading failed") @@ -87,7 +97,9 @@ async def _upload_attachments( result.append(upload_result) elif isinstance(attachment, File): - upload_result = await self.app.api.uploads.upload_file(attachment) + upload_result = await self.app.api.uploads.upload_file( + attachment + ) if not upload_result: logger.error("File uploading failed") raise UploadError("File uploading failed") @@ -105,7 +117,9 @@ async def send_message( *, notify: bool = True, ) -> Message | None: - logger.info("sending message chat_id=%s text_len=%s", chat_id, len(text)) + logger.info( + "sending message chat_id=%s text_len=%s", chat_id, len(text) + ) clean_text, elements = Formatter.format_markdown(text) @@ -157,7 +171,10 @@ async def fetch_history( Opcode.CHAT_HISTORY, payload=frame.to_payload(), ) - return parse_payload_list(response, MessagePayloadKey.MESSAGES, Message) or None + return ( + parse_payload_list(response, MessagePayloadKey.MESSAGES, Message) + or None + ) async def delete_message( self, @@ -178,7 +195,9 @@ async def delete_message( ) await self.app.invoke(Opcode.MSG_DELETE, frame.to_payload()) - logger.info("messages deleted chat_id=%s count=%s", chat_id, len(message_ids)) + logger.info( + "messages deleted chat_id=%s count=%s", chat_id, len(message_ids) + ) return True async def pin_message( @@ -200,7 +219,9 @@ async def pin_message( ) await self.app.invoke(Opcode.CHAT_UPDATE, frame.to_payload()) - logger.info("message pinned chat_id=%s message_id=%s", chat_id, message_id) + logger.info( + "message pinned chat_id=%s message_id=%s", chat_id, message_id + ) return True async def get_video_by_id( @@ -242,7 +263,9 @@ async def get_file_by_id( file_id=file_id, ) - response = await self.app.invoke(Opcode.FILE_DOWNLOAD, frame.to_payload()) + response = await self.app.invoke( + Opcode.FILE_DOWNLOAD, frame.to_payload() + ) return parse_payload_model(response, FileRequest) async def add_reaction( @@ -263,7 +286,9 @@ async def add_reaction( reaction=ReactionInfoPayload(id=reaction), ) - response = await self.app.invoke(Opcode.MSG_REACTION, frame.to_payload()) + response = await self.app.invoke( + Opcode.MSG_REACTION, frame.to_payload() + ) reaction_info = payload_item(response, MessagePayloadKey.REACTION_INFO) if reaction_info: return ReactionInfo.model_validate(reaction_info) diff --git a/src/pymax/api/self/service.py b/src/pymax/api/self/service.py index 057adfe..3f9dd61 100644 --- a/src/pymax/api/self/service.py +++ b/src/pymax/api/self/service.py @@ -37,7 +37,9 @@ def __init__(self, app: App) -> None: async def request_profile_photo_upload_url(self) -> str: logger.info("requesting profile photo upload url") frame = UploadPayload(profile=True) - response = await self.app.invoke(Opcode.PHOTO_UPLOAD, frame.to_payload()) + response = await self.app.invoke( + Opcode.PHOTO_UPLOAD, frame.to_payload() + ) return str(require_payload_item(response, SelfPayloadKey.URL)) async def change_profile( @@ -84,13 +86,17 @@ async def create_folder( include=chat_include, filters=filters or [], ) - response = await self.app.invoke(Opcode.FOLDERS_UPDATE, frame.to_payload()) + response = await self.app.invoke( + Opcode.FOLDERS_UPDATE, frame.to_payload() + ) return require_payload_model(response, FolderUpdate) async def get_folders(self, folder_sync: int = 0) -> FolderList: logger.info("fetching folders") frame = GetFolderPayload(folder_sync=folder_sync) - response = await self.app.invoke(Opcode.FOLDERS_GET, frame.to_payload()) + response = await self.app.invoke( + Opcode.FOLDERS_GET, frame.to_payload() + ) return require_payload_model(response, FolderList) async def update_folder( @@ -109,13 +115,17 @@ async def update_folder( filters=filters or [], options=options or [], ) - response = await self.app.invoke(Opcode.FOLDERS_UPDATE, frame.to_payload()) + response = await self.app.invoke( + Opcode.FOLDERS_UPDATE, frame.to_payload() + ) return require_payload_model(response, FolderUpdate) async def delete_folder(self, folder_id: str) -> FolderUpdate: logger.info("deleting folder") frame = DeleteFolderPayload(folder_ids=[folder_id]) - response = await self.app.invoke(Opcode.FOLDERS_DELETE, frame.to_payload()) + response = await self.app.invoke( + Opcode.FOLDERS_DELETE, frame.to_payload() + ) return require_payload_model(response, FolderUpdate) async def close_all_sessions(self) -> bool: @@ -129,7 +139,9 @@ async def close_all_sessions(self) -> bool: token = payload_item(response, SelfPayloadKey.TOKEN, str) if not token: - logger.warning("no token received after closing sessions, skipping token update") + logger.warning( + "no token received after closing sessions, skipping token update" + ) return False await self.app.store.update_token(self.app.session.token, token) diff --git a/src/pymax/api/session/payloads.py b/src/pymax/api/session/payloads.py index eab2938..2bdf64f 100644 --- a/src/pymax/api/session/payloads.py +++ b/src/pymax/api/session/payloads.py @@ -52,10 +52,17 @@ def to_web_payload(self) -> dict: by_alias=True, exclude_none=True, ) - if self.device_type == DeviceType.WEB and "headerUserAgent" not in payload: + if ( + self.device_type == DeviceType.WEB + and "headerUserAgent" not in payload + ): payload["headerUserAgent"] = DEFAULT_WEB_HEADER_USER_AGENT - return {alias: payload[alias] for alias in WEB_USER_AGENT_ALIASES if alias in payload} + return { + alias: payload[alias] + for alias in WEB_USER_AGENT_ALIASES + if alias in payload + } class MobileHandshakePayload(CamelModel): diff --git a/src/pymax/api/uploads/models.py b/src/pymax/api/uploads/models.py index 7e4c438..7f96811 100644 --- a/src/pymax/api/uploads/models.py +++ b/src/pymax/api/uploads/models.py @@ -1,9 +1,6 @@ -from typing import Literal - -from pydantic import BaseModel, Field +from pydantic import BaseModel from pymax.api.models import CamelModel -from pymax.types import AttachmentType class PhotoPayloadResponse(BaseModel): diff --git a/src/pymax/api/uploads/payloads.py b/src/pymax/api/uploads/payloads.py index 5dc7794..909be22 100644 --- a/src/pymax/api/uploads/payloads.py +++ b/src/pymax/api/uploads/payloads.py @@ -5,18 +5,24 @@ class AttachPhotoPayload(CamelModel): - type: AttachmentType = Field(default=AttachmentType.PHOTO, serialization_alias="_type") + type: AttachmentType = Field( + default=AttachmentType.PHOTO, serialization_alias="_type" + ) photo_token: str class VideoAttachPayload(CamelModel): - type: AttachmentType = Field(default=AttachmentType.VIDEO, serialization_alias="_type") + type: AttachmentType = Field( + default=AttachmentType.VIDEO, serialization_alias="_type" + ) video_id: int token: str class AttachFilePayload(CamelModel): - type: AttachmentType = Field(default=AttachmentType.FILE, serialization_alias="_type") + type: AttachmentType = Field( + default=AttachmentType.FILE, serialization_alias="_type" + ) file_id: int diff --git a/src/pymax/api/uploads/service.py b/src/pymax/api/uploads/service.py index aca0705..abaeb55 100644 --- a/src/pymax/api/uploads/service.py +++ b/src/pymax/api/uploads/service.py @@ -38,10 +38,18 @@ class UploadService: def __init__(self, app: App) -> None: self.app = app - self.video_upload_waiters: dict[int, asyncio.Future[VideoUploadSignal]] = {} - self.file_upload_waiters: dict[int, asyncio.Future[FileUploadSignal]] = {} - self.app.dispatcher.on_internal(EventType.VIDEO_READY)(self.on_video_attach) - self.app.dispatcher.on_internal(EventType.FILE_READY)(self.on_file_attach) + self.video_upload_waiters: dict[ + int, asyncio.Future[VideoUploadSignal] + ] = {} + self.file_upload_waiters: dict[ + int, asyncio.Future[FileUploadSignal] + ] = {} + self.app.dispatcher.on_internal(EventType.VIDEO_READY)( + self.on_video_attach + ) + self.app.dispatcher.on_internal(EventType.FILE_READY)( + self.on_file_attach + ) async def upload_photo(self, photo: Photo) -> AttachPhotoPayload: logger.info("Uploading photo") @@ -62,7 +70,9 @@ async def upload_photo(self, photo: Photo) -> AttachPhotoPayload: url = payload_item(data, "url", str) # TODO: ENUM!!!! except Exception as e: logger.exception("Failed to parse photo upload URL from response") - raise UploadError("Failed to parse photo upload URL from response") from e + raise UploadError( + "Failed to parse photo upload URL from response" + ) from e if not url: logger.error("No upload URL received") @@ -80,11 +90,15 @@ async def upload_photo(self, photo: Photo) -> AttachPhotoPayload: except (KeyError, IndexError) as e: logger.exception("Photo upload URL does not contain photoIds") logger.debug("Invalid photo upload URL=%s", url) - raise UploadError("Photo upload URL does not contain photoIds") from e + raise UploadError( + "Photo upload URL does not contain photoIds" + ) from e except Exception as e: logger.exception("Failed to parse photo id from upload URL") logger.debug("Invalid photo upload URL=%s", url) - raise UploadError("Failed to parse photo id from upload URL") from e + raise UploadError( + "Failed to parse photo id from upload URL" + ) from e logger.debug("Photo upload id parsed photo_id=%s", photo_id) @@ -122,23 +136,33 @@ async def upload_photo(self, photo: Photo) -> AttachPhotoPayload: try: async with ( - aiohttp.ClientSession() as session, + aiohttp.ClientSession(proxy=self.app.config.proxy) as session, session.post( url=url, data=form, ) as response, ): - logger.debug("Photo upload HTTP response status=%s", response.status) + logger.debug( + "Photo upload HTTP response status=%s", response.status + ) if response.status != HTTPStatus.OK: - logger.error("Photo upload failed with status %s", response.status) - raise UploadError(f"Photo upload failed with status {response.status}") + logger.error( + "Photo upload failed with status %s", response.status + ) + raise UploadError( + f"Photo upload failed with status {response.status}" + ) try: result = await response.json() except Exception as e: - logger.exception("Failed to decode photo upload response JSON") - raise UploadError("Failed to decode photo upload response JSON") from e + logger.exception( + "Failed to decode photo upload response JSON" + ) + raise UploadError( + "Failed to decode photo upload response JSON" + ) from e except UploadError: raise @@ -251,8 +275,12 @@ async def upload_video(self, video: Video) -> VideoAttachPayload: logger.debug("Video upload waiter registered video_id=%s", video_id) try: - async with aiohttp.ClientSession(timeout=timeout) as session: - logger.debug("Starting video upload HTTP request video_id=%s", video_id) + async with aiohttp.ClientSession( + timeout=timeout, proxy=self.app.config.proxy + ) as session: + logger.debug( + "Starting video upload HTTP request video_id=%s", video_id + ) async with session.post( url=upload_info.url, @@ -297,14 +325,26 @@ async def upload_video(self, video: Video) -> VideoAttachPayload: except UploadError: raise except aiohttp.ClientError as e: - logger.exception("HTTP error during video upload video_id=%s", video_id) - raise UploadError(f"HTTP error during video upload video_id={video_id}") from e + logger.exception( + "HTTP error during video upload video_id=%s", video_id + ) + raise UploadError( + f"HTTP error during video upload video_id={video_id}" + ) from e except asyncio.TimeoutError as e: - logger.exception("Timed out during video upload video_id=%s", video_id) - raise UploadError(f"Timed out during video upload video_id={video_id}") from e + logger.exception( + "Timed out during video upload video_id=%s", video_id + ) + raise UploadError( + f"Timed out during video upload video_id={video_id}" + ) from e except Exception as e: - logger.exception("Unexpected error during video upload video_id=%s", video_id) - raise UploadError(f"Unexpected error during video upload video_id={video_id}") from e + logger.exception( + "Unexpected error during video upload video_id=%s", video_id + ) + raise UploadError( + f"Unexpected error during video upload video_id={video_id}" + ) from e finally: self.video_upload_waiters.pop(video_id, None) logger.debug("Video upload waiter removed video_id=%s", video_id) @@ -371,7 +411,9 @@ async def upload_file(self, file: File) -> AttachFilePayload: logger.debug("File upload waiter registered file_id=%s", file_id) try: - async with aiohttp.ClientSession() as session: + async with aiohttp.ClientSession( + proxy=self.app.config.proxy, + ) as session: async with session.post( url=upload_info.url, headers=headers, @@ -414,44 +456,70 @@ async def upload_file(self, file: File) -> AttachFilePayload: except UploadError: raise except aiohttp.ClientError as e: - logger.exception("HTTP error during file upload file_id=%s", file_id) - raise UploadError(f"HTTP error during file upload file_id={file_id}") from e + logger.exception( + "HTTP error during file upload file_id=%s", file_id + ) + raise UploadError( + f"HTTP error during file upload file_id={file_id}" + ) from e except asyncio.TimeoutError as e: - logger.exception("Timed out during file upload file_id=%s", file_id) - raise UploadError(f"Timed out during file upload file_id={file_id}") from e + logger.exception( + "Timed out during file upload file_id=%s", file_id + ) + raise UploadError( + f"Timed out during file upload file_id={file_id}" + ) from e except Exception as e: - logger.exception("Unexpected error during file upload file_id=%s", file_id) - raise UploadError(f"Unexpected error during file upload file_id={file_id}") from e + logger.exception( + "Unexpected error during file upload file_id=%s", file_id + ) + raise UploadError( + f"Unexpected error during file upload file_id={file_id}" + ) from e finally: self.file_upload_waiters.pop(file_id, None) logger.debug("File upload waiter removed file=%s", file_id) - async def on_video_attach(self, attach: VideoUploadSignal, _: Client) -> None: + async def on_video_attach( + self, attach: VideoUploadSignal, _: Client + ) -> None: logger.debug("Received attach event video_id=%s", attach.video_id) future = self.video_upload_waiters.pop(attach.video_id, None) if not future: - logger.debug("No video upload waiter found video_id=%s", attach.video_id) + logger.debug( + "No video upload waiter found video_id=%s", attach.video_id + ) return if future.done(): - logger.debug("Video upload waiter already done video_id=%s", attach.video_id) + logger.debug( + "Video upload waiter already done video_id=%s", attach.video_id + ) return future.set_result(attach) - logger.debug("Video upload waiter resolved video_id=%s", attach.video_id) + logger.debug( + "Video upload waiter resolved video_id=%s", attach.video_id + ) - async def on_file_attach(self, attach: FileUploadSignal, _: Client) -> None: + async def on_file_attach( + self, attach: FileUploadSignal, _: Client + ) -> None: logger.debug("Received attach event file_id=%s", attach.file_id) future = self.file_upload_waiters.pop(attach.file_id, None) if not future: - logger.debug("No file upload waiter found file_id=%s", attach.file_id) + logger.debug( + "No file upload waiter found file_id=%s", attach.file_id + ) return if future.done(): - logger.debug("File upload waiter already done file_id=%s", attach.file_id) + logger.debug( + "File upload waiter already done file_id=%s", attach.file_id + ) return future.set_result(attach) diff --git a/src/pymax/api/users/service.py b/src/pymax/api/users/service.py index a58bf57..3bf0b3a 100644 --- a/src/pymax/api/users/service.py +++ b/src/pymax/api/users/service.py @@ -44,7 +44,9 @@ async def get_users(self, user_ids: list[int]) -> list[User]: for user_id in user_ids if (user := self.get_cached_user(user_id)) is not None } - missing_ids = [user_id for user_id in user_ids if user_id not in cached] + missing_ids = [ + user_id for user_id in user_ids if user_id not in cached + ] if missing_ids: for user in await self.fetch_users(missing_ids): @@ -62,11 +64,15 @@ async def get_user(self, user_id: int) -> User | None: async def fetch_users(self, user_ids: list[int]) -> list[User]: logger.info("fetching users count=%s", len(user_ids)) frame = FetchContactsPayload(contact_ids=user_ids) - response = await self.app.invoke(Opcode.CONTACT_INFO, frame.to_payload()) + response = await self.app.invoke( + Opcode.CONTACT_INFO, frame.to_payload() + ) users = [ self._cache_user(user) - for user in parse_payload_list(response, UserPayloadKey.CONTACTS, User) + for user in parse_payload_list( + response, UserPayloadKey.CONTACTS, User + ) ] logger.debug("fetched users count=%s", len(users)) return users @@ -91,8 +97,12 @@ async def get_sessions(self) -> list[Session]: response = await self.app.invoke(Opcode.SESSIONS_INFO, {}) return parse_payload_list(response, UserPayloadKey.SESSIONS, Session) - async def _contact_action(self, payload: ContactActionPayload) -> InboundFrame: - response = await self.app.invoke(Opcode.CONTACT_UPDATE, payload.to_payload()) + async def _contact_action( + self, payload: ContactActionPayload + ) -> InboundFrame: + response = await self.app.invoke( + Opcode.CONTACT_UPDATE, payload.to_payload() + ) require_payload_dict(response) return response diff --git a/src/pymax/app.py b/src/pymax/app.py index 3c0d071..c1d904b 100644 --- a/src/pymax/app.py +++ b/src/pymax/app.py @@ -33,7 +33,9 @@ def __init__( self.dispatcher: Dispatcher[ClientT] = Dispatcher(self, root_router) self.api = ApiFacade(self) self.config = config - self.store = self.config.store or SessionStore(config.work_dir, config.session_name) + self.store = self.config.store or SessionStore( + config.work_dir, config.session_name + ) self.auth_flow = auth_flow self.me: Profile | None = None @@ -74,14 +76,18 @@ async def start(self) -> None: await self.connection.open() handshake_device_id = ( - session_data.device_id if session_data else self.config.device.device_id + session_data.device_id + if session_data + else self.config.device.device_id ) logger.debug("running handshake") await self.handshake(handshake_device_id) except (ConnectionError, EOFError, OSError, TimeoutError) as e: logger.exception("failed to connect or handshake") await self.connection.close() - raise ConnectionError(f"Failed to connect and handshake: {e}") from e + raise ConnectionError( + f"Failed to connect and handshake: {e}" + ) from e self._ping_task = asyncio.create_task(self._ping_loop()) @@ -102,7 +108,9 @@ async def start(self) -> None: if not auth_result.token: logger.error("authentication finished without token") - raise RuntimeError("Authentication failed: no token received") + raise RuntimeError( + "Authentication failed: no token received" + ) await self.store.save_session( session_data := SessionInfo( @@ -200,7 +208,9 @@ async def invoke( ) logger.debug("Request data=%s", frame.model_dump()) response = await self.connection.request(frame, timeout=timeout) - response_keys = sorted(response.payload.keys()) if response.payload else [] + response_keys = ( + sorted(response.payload.keys()) if response.payload else [] + ) logger.debug( "response opcode=%s cmd=%s seq=%s payload_keys=%s", response.opcode, diff --git a/src/pymax/auth/qr.py b/src/pymax/auth/qr.py index 157bc18..c8f15d0 100644 --- a/src/pymax/auth/qr.py +++ b/src/pymax/auth/qr.py @@ -4,8 +4,7 @@ import time from typing import TYPE_CHECKING -import qrcode - +from pymax.auth.base import AuthFlow from pymax.exceptions import ApiError from pymax.logging import get_logger @@ -20,7 +19,7 @@ logger = get_logger(__name__) -class QrAuthFlow: +class QrAuthFlow(AuthFlow): """Стандартная QR-авторизация ``WebClient``. Flow получает QR-ссылку, передает ее в ``QrHandler``, ждет подтверждения и @@ -119,17 +118,23 @@ async def _authenticate_with_password( continue try: - response = await app.api.auth.check_password(track_id, password) + response = await app.api.auth.check_password( + track_id, password + ) except ApiError as e: logger.error("2fa password check failed: %s", e) continue if response.error: - logger.error("2fa password check failed error=%s", response.error) + logger.error( + "2fa password check failed error=%s", response.error + ) continue if response.login_token: logger.info("2fa password authentication completed") return response.login_token - logger.error("2fa password response did not contain login token; retrying") + logger.error( + "2fa password response did not contain login token; retrying" + ) diff --git a/src/pymax/auth/sms.py b/src/pymax/auth/sms.py index 367bdc4..89a6f0d 100644 --- a/src/pymax/auth/sms.py +++ b/src/pymax/auth/sms.py @@ -2,11 +2,16 @@ from typing import TYPE_CHECKING +from pymax.auth.base import AuthFlow from pymax.exceptions import ApiError from pymax.logging import get_logger from .models import AuthResult -from .providers import ConsolePasswordProvider, PasswordProvider, SmsCodeProvider +from .providers import ( + ConsolePasswordProvider, + PasswordProvider, + SmsCodeProvider, +) if TYPE_CHECKING: from pymax.app import App @@ -15,7 +20,7 @@ logger = get_logger(__name__) -class SmsAuthFlow: +class SmsAuthFlow(AuthFlow): """Стандартная SMS-авторизация ``Client``. Flow запрашивает SMS-код, отправляет его в Max, при необходимости проходит @@ -104,13 +109,17 @@ async def _authenticate_with_password( continue try: - response = await app.api.auth.check_password(track_id, password) + response = await app.api.auth.check_password( + track_id, password + ) except ApiError as e: logger.error("2fa password check failed: %s", e) continue if response.error: - logger.error("2fa password check failed error=%s", response.error) + logger.error( + "2fa password check failed error=%s", response.error + ) continue if response.login_token: diff --git a/src/pymax/base.py b/src/pymax/base.py index a9135f5..af22960 100644 --- a/src/pymax/base.py +++ b/src/pymax/base.py @@ -82,6 +82,7 @@ def _build_config( telemetry=self.extra_config.telemetry, sync=self.extra_config.sync, store=self.extra_config.store, + proxy=self.extra_config.proxy, device=DeviceConfig( mt_instance_id=self.extra_config.mt_instance_id, device_id=self.extra_config.device_id or str(uuid4()), diff --git a/src/pymax/client.py b/src/pymax/client.py index 20d66b0..c9bee67 100644 --- a/src/pymax/client.py +++ b/src/pymax/client.py @@ -66,7 +66,10 @@ def __init__( self._config = self._build_config( phone=phone, - user_agent=(self.extra_config.user_agent or self.extra_config.generate_user_agent()), + user_agent=( + self.extra_config.user_agent + or self.extra_config.generate_user_agent() + ), ) if auth_flow is None: diff --git a/src/pymax/client_web.py b/src/pymax/client_web.py index 0ce5d69..22c8c8d 100644 --- a/src/pymax/client_web.py +++ b/src/pymax/client_web.py @@ -1,5 +1,6 @@ from __future__ import annotations +from pymax.auth.base import AuthFlow from pymax.auth.providers import ConsoleQrHandler, QrHandler from pymax.auth.qr import QrAuthFlow from pymax.connection import ConnectionManager @@ -34,7 +35,7 @@ def __init__( session_name: str = "session.db", work_dir: str = ".", extra_config: ExtraConfig | None = None, - auth_flow: QrAuthFlow | None = None, + auth_flow: AuthFlow | None = None, qr_provider: QrHandler | None = None, ) -> None: self.extra_config = extra_config or ExtraConfig() @@ -53,7 +54,8 @@ def __init__( self._config = self._build_config( phone=None, user_agent=( - self.extra_config.user_agent or self.extra_config.generate_web_user_agent() + self.extra_config.user_agent + or self.extra_config.generate_web_user_agent() ), ) diff --git a/src/pymax/config.py b/src/pymax/config.py index 2d34068..27677cf 100644 --- a/src/pymax/config.py +++ b/src/pymax/config.py @@ -8,7 +8,7 @@ DEFAULT_WEB_HEADER_USER_AGENT, MobileUserAgentPayload, ) -from pymax.session import SessionStore, StoreProtocol +from pymax.session import StoreProtocol from pymax.types.domain.sync import SyncOverrides APP_VERSIONS: tuple[tuple[str, int], ...] = ( @@ -35,7 +35,12 @@ ("Xiaomi 2201117TG", "Android 13", "395dpi 395dpi 1080x2400", "arm64-v8a"), ("Xiaomi 2201123G", "Android 14", "526dpi 526dpi 1440x3200", "arm64-v8a"), ("Xiaomi 2210132G", "Android 14", "446dpi 446dpi 1220x2712", "arm64-v8a"), - ("Xiaomi 23049PCD8G", "Android 14", "446dpi 446dpi 1220x2712", "arm64-v8a"), + ( + "Xiaomi 23049PCD8G", + "Android 14", + "446dpi 446dpi 1220x2712", + "arm64-v8a", + ), ("Redmi 2201116TG", "Android 13", "395dpi 395dpi 1080x2400", "arm64-v8a"), ("Redmi 22101316G", "Android 13", "395dpi 395dpi 1080x2400", "arm64-v8a"), ("Redmi 23021RAA2Y", "Android 14", "395dpi 395dpi 1080x2400", "arm64-v8a"), @@ -87,6 +92,7 @@ class ClientConfig(BaseModel): session_name: str = "session.db" device: DeviceConfig token: str | None = None + proxy: str | None = None host: str = "api.oneme.ru" port: int = 443 @@ -103,7 +109,9 @@ class ClientConfig(BaseModel): def ensure_config(self) -> None: if not self.phone: - raise ValueError("Phone must be provided when no saved session exists.") + raise ValueError( + "Phone must be provided when no saved session exists." + ) class ExtraConfig(BaseModel): diff --git a/src/pymax/connection/connection.py b/src/pymax/connection/connection.py index c9f8d0c..d5c407b 100644 --- a/src/pymax/connection/connection.py +++ b/src/pymax/connection/connection.py @@ -80,6 +80,7 @@ async def fail(self, exc: Exception | None = None) -> None: self._connection_lost = True self.requests.cancel_all(exc=exc) await self.transport.close() + self._is_open = False async def send(self, frame: OutboundFrame) -> None: if not self._is_open: @@ -145,19 +146,28 @@ async def _recv_loop(self) -> None: model = self.protocol.decode(frame) await self._handle_inbound(model) - except EOFError as e: + except EOFError: logger.warning("connection closed by server") - self.requests.cancel_all(exc=ConnectionError("Connection closed by the server")) + self.requests.cancel_all( + exc=ConnectionError("Connection closed by the server") + ) self._connection_lost = True + self._is_open = False except TimeoutError as e: logger.exception("connection timed out") - self.requests.cancel_all(exc=ConnectionError("Connection timed out")) + self.requests.cancel_all( + exc=ConnectionError("Connection timed out") + ) self._connection_lost = True + self._is_open = False raise e except Exception as e: logger.exception("connection receive loop failed") - self.requests.cancel_all(exc=ConnectionError(f"Connection error: {e}")) + self.requests.cancel_all( + exc=ConnectionError(f"Connection error: {e}") + ) self._connection_lost = True + self._is_open = False raise e async def _handle_inbound(self, frame: InboundFrame) -> None: @@ -197,7 +207,7 @@ async def _dispatch_event(self, frame: InboundFrame) -> None: ) def next_seq(self) -> int: - self._seq = (self._seq + 1) % 0xFFFFFFFF + self._seq = (self._seq + 1) % 0x10000 return self._seq @property diff --git a/src/pymax/connection/readers/tcp.py b/src/pymax/connection/readers/tcp.py index 78c3c85..d6bbf39 100644 --- a/src/pymax/connection/readers/tcp.py +++ b/src/pymax/connection/readers/tcp.py @@ -8,13 +8,15 @@ class TCPReader(BaseReader): - def __init__(self, transport: TCPTransport, framer: TcpPacketFramer) -> None: + def __init__( + self, transport: TCPTransport, framer: TcpPacketFramer + ) -> None: super().__init__() self.transport = transport self.framer = framer async def read(self) -> bytes: - header_bytes = await self.transport.recv(10) + header_bytes = await self.transport.recv(self.framer.HEADER_SIZE) payload_len = self.framer.unpack_header(header_bytes) if payload_len is None: logger.warning( diff --git a/src/pymax/dispatch/dispatcher.py b/src/pymax/dispatch/dispatcher.py index b1f4e3e..a994d9b 100644 --- a/src/pymax/dispatch/dispatcher.py +++ b/src/pymax/dispatch/dispatcher.py @@ -9,7 +9,6 @@ from pymax.protocol import InboundFrame from pymax.types import Chat, MessageDeleteEvent from pymax.types.domain import Message -from pymax.types.events import FileUploadSignal, VideoUploadSignal from .enums import EventType from .mapping import EventMapper, EventResolver @@ -26,7 +25,6 @@ from collections.abc import Generator from pymax.app import App - from pymax.client import Client logger = get_logger(__name__) @@ -35,7 +33,9 @@ class Dispatcher(Generic[ClientT]): - def __init__(self, app: App, root_router: Router[ClientT] | None = None) -> None: + def __init__( + self, app: App, root_router: Router[ClientT] | None = None + ) -> None: self.root_router: Router[ClientT] = root_router or Router() self.internal_router: Router[ClientT] = Router() self.resolver = EventResolver() @@ -59,7 +59,11 @@ def on_internal( event: EventType, *filters: FilterCallback[Any], ) -> HandlerDecorator[Any, ClientT]: - logger.debug("registering internal handler event=%s filters=%s", event, len(filters)) + logger.debug( + "registering internal handler event=%s filters=%s", + event, + len(filters), + ) return self.internal_router.on(event, *filters) def on( @@ -67,7 +71,9 @@ def on( event: EventType, *filters: FilterCallback[Any], ) -> HandlerDecorator[Any, ClientT]: - logger.debug("registering handler event=%s filters=%s", event, len(filters)) + logger.debug( + "registering handler event=%s filters=%s", event, len(filters) + ) return self.root_router.on(event, *filters) def on_message( @@ -81,7 +87,9 @@ def on_message_edit( self, *filters: FilterCallback[Message], ) -> HandlerDecorator[Message, ClientT]: - logger.debug("registering message edit handler filters=%s", len(filters)) + logger.debug( + "registering message edit handler filters=%s", len(filters) + ) return self.root_router.on_message_edit(*filters) def on_message_delete( @@ -108,7 +116,9 @@ def on_start(self) -> StartDecorator[ClientT]: def iter_routers(self) -> Generator[Router[ClientT], Any, None]: yield from self._iter_router(self.root_router) - def _iter_router(self, router: Router[ClientT]) -> Generator[Router[ClientT], Any, None]: + def _iter_router( + self, router: Router[ClientT] + ) -> Generator[Router[ClientT], Any, None]: yield router for child in router.children: @@ -151,10 +161,16 @@ async def dispatch(self, frame: InboundFrame) -> None: if event_type is not None: logger.debug("dispatching event type=%s", event_type) event = self.mapper.map(event_type, frame) - await self._dispatch_to_router(self.internal_router, event_type, event) + await self._dispatch_to_router( + self.internal_router, event_type, event + ) await self._dispatch_to_router(self.root_router, event_type, event) else: - logger.debug("dispatching raw event only opcode=%s cmd=%s", frame.opcode, frame.cmd) + logger.debug( + "dispatching raw event only opcode=%s cmd=%s", + frame.opcode, + frame.cmd, + ) await self._dispatch_to_router(self.root_router, EventType.RAW, frame) @@ -193,7 +209,9 @@ async def _matches( return False return True - async def _call(self, callback: HandlerCallback[Any, ClientT], event: Any) -> Any: + async def _call( + self, callback: HandlerCallback[Any, ClientT], event: Any + ) -> Any: if self.client is None: raise RuntimeError("client is not bound") diff --git a/src/pymax/dispatch/mapping.py b/src/pymax/dispatch/mapping.py index 724cb6d..397e331 100644 --- a/src/pymax/dispatch/mapping.py +++ b/src/pymax/dispatch/mapping.py @@ -10,7 +10,12 @@ from pymax.types.events import FileUploadSignal, VideoUploadSignal from .enums import EventType -from .resolvers import resolve_attach, resolve_chat, resolve_message, resolve_message_delete +from .resolvers import ( + resolve_attach, + resolve_chat, + resolve_message, + resolve_message_delete, +) if TYPE_CHECKING: from pymax.app import App @@ -53,7 +58,9 @@ def map(self, event_type: EventType, frame: InboundFrame): if frame.payload: if event_type in (EventType.MESSAGE_NEW, EventType.MESSAGE_EDIT): - return Message.model_validate(frame.payload).bind(self.app.api.messages) + return Message.model_validate(frame.payload).bind( + self.app.api.messages + ) elif event_type == EventType.CHAT_UPDATE: return Chat.model_validate(frame.payload["chat"]).bind( self.app.api.messages, diff --git a/src/pymax/dispatch/router.py b/src/pymax/dispatch/router.py index 34a48bd..3b90364 100644 --- a/src/pymax/dispatch/router.py +++ b/src/pymax/dispatch/router.py @@ -106,6 +106,7 @@ def on( async def raw(frame: InboundFrame, client: Client) -> None: print(frame.payload) """ + def decorator( handler: HandlerCallback[_EventT, ClientT], ) -> HandlerCallback[_EventT, ClientT]: @@ -136,6 +137,7 @@ def on_start(self) -> StartDecorator: Returns: Декоратор для ``handler(client)``. """ + def decorator(handler: StartCallback) -> StartCallback: self.on_start_handler = handler return handler diff --git a/src/pymax/files/base.py b/src/pymax/files/base.py index 4bd0c6c..b34396b 100644 --- a/src/pymax/files/base.py +++ b/src/pymax/files/base.py @@ -8,7 +8,12 @@ class BaseFile(ABC): def __init__( - self, raw: bytes | None = None, *, path: str | None, url: str | None, name: str | None + self, + raw: bytes | None = None, + *, + path: str | None, + url: str | None, + name: str | None, ) -> None: self.path = path self.url = url diff --git a/src/pymax/formatting/markdown.py b/src/pymax/formatting/markdown.py index b6480be..f228688 100644 --- a/src/pymax/formatting/markdown.py +++ b/src/pymax/formatting/markdown.py @@ -151,7 +151,10 @@ def format_markdown(text: str) -> tuple[str, list[Element]]: if marker == "```": closing_index = text.find(marker, i + marker_len) - if closing_index == -1 or closing_index == i + marker_len: + if ( + closing_index == -1 + or closing_index == i + marker_len + ): clean_text += marker clean_pos += marker_len i += marker_len diff --git a/src/pymax/infra/auth.py b/src/pymax/infra/auth.py index e2f5759..932034b 100644 --- a/src/pymax/infra/auth.py +++ b/src/pymax/infra/auth.py @@ -53,3 +53,45 @@ async def remove_2fa(self, password: str) -> bool: RuntimeError: Если удаление пароля не удалось. """ return await self._app.api.auth.remove_2fa(password=password) + + async def change_password( + self, + password_old: str, + password_new: str, + ) -> bool: + """Меняет пароль 2FA для текущей учетной записи. + + Args: + password_old: Текущий пароль 2FA. + password_new: Новый пароль 2FA. + + Returns: + ``True``, если пароль успешно изменен. + + Raises: + RuntimeError: Если изменение пароля не удалось. + """ + return await self._app.api.auth.change_password( + password_old=password_old, + password_new=password_new, + ) + + async def authorize_qr_login(self, qr_link: str) -> bool: + """Авторизует вход по QR-коду. + + Args: + qr_link: Ссылка на QR-код для авторизации. + + Returns: + ``True``, если вход по QR-коду успешно авторизован. + """ + return await self._app.api.auth.authorize_qr_login(qr_link=qr_link) + + async def check_2fa(self) -> bool: + """Проверяет, включена ли 2FA для текущей учетной записи. + + Returns: + ``True``, если на аккаунте установлен пароль 2FA. + """ + + return await self._app.api.auth.check_2fa() diff --git a/src/pymax/infra/base.py b/src/pymax/infra/base.py index 7625499..4551d01 100644 --- a/src/pymax/infra/base.py +++ b/src/pymax/infra/base.py @@ -1,4 +1,5 @@ from .auth import AuthMixin +from .bots import BotsMixin from .chat import ChatMixin from .message import MessageMixin from .self import SelfMixin @@ -10,6 +11,7 @@ class BaseMixin( UserMixin, ChatMixin, MessageMixin, + BotsMixin, AuthMixin, ): """Собирает публичные API-методы клиента.""" diff --git a/src/pymax/infra/bots.py b/src/pymax/infra/bots.py new file mode 100644 index 0000000..d64551f --- /dev/null +++ b/src/pymax/infra/bots.py @@ -0,0 +1,33 @@ +from pymax.types.domain import InitData + +from .protocol import IClientProtocol + + +class BotsMixin(IClientProtocol): + """Методы клиента для взаимодействия с ботами.""" + + async def get_bot_init_data( + self, + bot_id: int, + chat_id: int, + start_param: str | None = None, + ) -> InitData: + """Получает начальные данные для бота в контексте конкретного чата. + + Args: + bot_id: Идентификатор бота. + chat_id: Идентификатор чата, в котором бот будет использоваться. + start_param: Необязательный параметр, передаваемый при запуске + бота. + + Returns: + Объект с начальными данными для бота. + + Raises: + RuntimeError: Если получение данных не удалось. + """ + return await self._app.api.bots.get_init_data( + bot_id=bot_id, + chat_id=chat_id, + start_param=start_param, + ) diff --git a/src/pymax/infra/chat.py b/src/pymax/infra/chat.py index d92e3ce..5ca34cb 100644 --- a/src/pymax/infra/chat.py +++ b/src/pymax/infra/chat.py @@ -1,4 +1,4 @@ -from pymax.types import Chat, Message +from pymax.types import Chat, Member, Message from .protocol import IClientProtocol @@ -238,3 +238,104 @@ async def fetch_chats(self, marker: int | None = None) -> list[Chat]: Загруженные чаты. """ return await self._app.api.chats.fetch_chats(marker=marker) + + async def get_join_requests( + self, + chat_id: int, + count: int = 100, + ) -> list[Member]: + """Возвращает заявки на вступление в группу или канал. + + Args: + chat_id: ID группы или канала. + count: Максимальное количество заявок в ответе. + + Returns: + Список пользователей, ожидающих подтверждения заявки. + """ + return await self._app.api.chats.get_join_requests( + chat_id=chat_id, + count=count, + ) + + async def confirm_join_requests( + self, + chat_id: int, + user_ids: list[int], + show_history: bool = True, + ) -> Chat | None: + """Подтверждает несколько заявок на вступление. + + Args: + chat_id: ID группы или канала. + user_ids: ID пользователей, чьи заявки нужно подтвердить. + show_history: Показать новым участникам историю сообщений. + + Returns: + Обновленный чат или ``None``, если сервер не вернул чат. + """ + return await self._app.api.chats.confirm_join_requests( + chat_id=chat_id, + user_ids=user_ids, + show_history=show_history, + ) + + async def confirm_join_request( + self, + chat_id: int, + user_id: int, + show_history: bool = True, + ) -> Chat | None: + """Подтверждает одну заявку на вступление. + + Args: + chat_id: ID группы или канала. + user_id: ID пользователя, чью заявку нужно подтвердить. + show_history: Показать новому участнику историю сообщений. + + Returns: + Обновленный чат или ``None``, если сервер не вернул чат. + """ + return await self._app.api.chats.confirm_join_request( + chat_id=chat_id, + user_id=user_id, + show_history=show_history, + ) + + async def decline_join_requests( + self, + chat_id: int, + user_ids: list[int], + ) -> Chat | None: + """Отклоняет несколько заявок на вступление. + + Args: + chat_id: ID группы или канала. + user_ids: ID пользователей, чьи заявки нужно отклонить. + + Returns: + Обновленный чат или ``None``, если сервер не вернул чат. + """ + return await self._app.api.chats.decline_join_requests( + chat_id=chat_id, + user_ids=user_ids, + ) + + async def decline_join_request( + self, + chat_id: int, + user_id: int, + ) -> Chat | None: + """Отклоняет одну заявку на вступление. + + Args: + chat_id: ID группы или канала. + user_id: ID пользователя, чью заявку нужно отклонить. + + Returns: + Обновленный чат или ``None``, если сервер не вернул чат. + """ + return await self._app.api.chats.decline_join_request( + chat_id=chat_id, + user_id=user_id, + ) diff --git a/src/pymax/protocol/tcp/compression.py b/src/pymax/protocol/tcp/compression.py index 3de8e18..1a40a8a 100644 --- a/src/pymax/protocol/tcp/compression.py +++ b/src/pymax/protocol/tcp/compression.py @@ -1,5 +1,7 @@ class Lz4BlockCompression: - def decompress(self, src: bytes, max_output: int = 5 * 1024 * 1024) -> bytes: + def decompress( + self, src: bytes, max_output: int = 5 * 1024 * 1024 + ) -> bytes: dst = bytearray() pos = 0 diff --git a/src/pymax/protocol/tcp/framing.py b/src/pymax/protocol/tcp/framing.py index 8b99fd6..4d114ad 100644 --- a/src/pymax/protocol/tcp/framing.py +++ b/src/pymax/protocol/tcp/framing.py @@ -4,15 +4,9 @@ class TcpPacketFramer: - HEADER_STRUCT = struct.Struct(">BHBHI") + HEADER_STRUCT = struct.Struct(">BBHHI") HEADER_SIZE = HEADER_STRUCT.size - def _pack_cmd(self, cmd: int) -> int: - return (cmd & 0xFF) << 8 - - def _unpack_cmd(self, packed_cmd: int) -> int: - return (packed_cmd >> 8) & 0xFF - def pack( self, *, @@ -26,7 +20,7 @@ def pack( packed_len = ((flags & 0xFF) << 24) | (len(payload_bytes) & 0x00FFFFFF) header = self.HEADER_STRUCT.pack( ver, - self._pack_cmd(cmd), + cmd, seq, opcode, packed_len, @@ -37,7 +31,9 @@ def unpack(self, data: bytes) -> PackedPacket | None: if len(data) < self.HEADER_SIZE: return None - ver, cmd, seq, opcode, packed_len = self.HEADER_STRUCT.unpack_from(data, 0) + ver, cmd, seq, opcode, packed_len = self.HEADER_STRUCT.unpack_from( + data, 0 + ) flags = (packed_len >> 24) & 0xFF payload_len = packed_len & 0x00FFFFFF @@ -48,7 +44,7 @@ def unpack(self, data: bytes) -> PackedPacket | None: return PackedPacket( header=TcpPacketHeader( ver=ver, - cmd=self._unpack_cmd(cmd), + cmd=cmd, seq=seq, opcode=opcode, flags=flags, @@ -62,7 +58,6 @@ def unpack_header(self, data: bytes) -> int | None: return None _, _, _, _, packed_len = self.HEADER_STRUCT.unpack_from(data, 0) - flags = (packed_len >> 24) & 0xFF payload_len = packed_len & 0x00FFFFFF return payload_len diff --git a/src/pymax/protocol/tcp/payload.py b/src/pymax/protocol/tcp/payload.py index e337c8c..a93febb 100644 --- a/src/pymax/protocol/tcp/payload.py +++ b/src/pymax/protocol/tcp/payload.py @@ -7,7 +7,6 @@ from .compression import Lz4BlockCompression - logger = get_logger(__name__) @@ -44,7 +43,9 @@ def decode(self, payload_bytes: bytes) -> dict[Any, Any]: return {} try: - return msgpack.unpackb(payload_bytes, raw=False, strict_map_key=False) + return msgpack.unpackb( + payload_bytes, raw=False, strict_map_key=False + ) except msgpack.exceptions.ExtraData as e: if isinstance(e.unpacked, dict): logger.debug( diff --git a/src/pymax/protocol/tcp/protocol.py b/src/pymax/protocol/tcp/protocol.py index 83c651e..b7a70b5 100644 --- a/src/pymax/protocol/tcp/protocol.py +++ b/src/pymax/protocol/tcp/protocol.py @@ -3,7 +3,11 @@ from pymax.protocol.base import BaseProtocol from .framing import TcpPacketFramer -from .payload import Lz4BlockCompression, MsgpackPayloadCodec, TcpPayloadDecoder +from .payload import ( + Lz4BlockCompression, + MsgpackPayloadCodec, + TcpPayloadDecoder, +) logger = get_logger(__name__) @@ -21,7 +25,11 @@ def __init__(self) -> None: ) def encode(self, frame: OutboundFrame) -> bytes: - payload_bytes = self.serializer.encode(frame.payload) if frame.payload is not None else b"" + payload_bytes = ( + self.serializer.encode(frame.payload) + if frame.payload is not None + else b"" + ) flags = 0 @@ -44,7 +52,9 @@ def decode(self, raw: bytes | str) -> InboundFrame: packed_packet = self.framer.unpack(raw) if not packed_packet: - return InboundFrame(opcode=0, cmd=0, seq=None, payload=None, raw=None) + return InboundFrame( + opcode=0, cmd=0, seq=None, payload=None, raw=None + ) logger.debug( "tcp frame decoded header ver=%s cmd=%s seq=%s opcode=%s flags=%s payload_len=%s", diff --git a/src/pymax/protocol/ws/protocol.py b/src/pymax/protocol/ws/protocol.py index 47a30c0..9344b4b 100644 --- a/src/pymax/protocol/ws/protocol.py +++ b/src/pymax/protocol/ws/protocol.py @@ -20,8 +20,14 @@ def decode(self, raw: bytes | str) -> InboundFrame: data = json.loads(raw) return InboundFrame.model_validate(data) except json.JSONDecodeError: - logger.debug("failed to decode websocket frame json", exc_info=True) - return InboundFrame(opcode=0, cmd=0, seq=None, payload=None, raw=None) + logger.debug( + "failed to decode websocket frame json", exc_info=True + ) + return InboundFrame( + opcode=0, cmd=0, seq=None, payload=None, raw=None + ) except ValidationError: logger.debug("failed to validate websocket frame", exc_info=True) - return InboundFrame(opcode=0, cmd=0, seq=None, payload=None, raw=None) + return InboundFrame( + opcode=0, cmd=0, seq=None, payload=None, raw=None + ) diff --git a/src/pymax/session/protocol.py b/src/pymax/session/protocol.py index c529608..42a0b00 100644 --- a/src/pymax/session/protocol.py +++ b/src/pymax/session/protocol.py @@ -8,7 +8,11 @@ class StoreProtocol(Protocol): async def save_session(self, session_info: SessionInfo) -> None: ... async def update_token(self, old_token: str, new_token: str) -> None: ... async def load_session(self) -> SessionInfo | None: ... - async def load_session_by_device_id(self, device_id: str) -> SessionInfo | None: ... - async def load_session_by_phone(self, phone: str) -> SessionInfo | None: ... + async def load_session_by_device_id( + self, device_id: str + ) -> SessionInfo | None: ... + async def load_session_by_phone( + self, phone: str + ) -> SessionInfo | None: ... async def delete_session(self, token: str) -> None: ... async def close(self) -> None: ... diff --git a/src/pymax/session/store.py b/src/pymax/session/store.py index 6299d8a..ec1e1ba 100644 --- a/src/pymax/session/store.py +++ b/src/pymax/session/store.py @@ -55,12 +55,24 @@ async def _initialize_db(self, conn: aiosqlite.Connection) -> None: ) """ ) - await self._ensure_column(conn, "mt_instance_id", "TEXT NOT NULL DEFAULT ''") - await self._ensure_column(conn, "chats_sync", "INTEGER NOT NULL DEFAULT -1") - await self._ensure_column(conn, "contacts_sync", "INTEGER NOT NULL DEFAULT -1") - await self._ensure_column(conn, "drafts_sync", "INTEGER NOT NULL DEFAULT -1") - await self._ensure_column(conn, "presence_sync", "INTEGER NOT NULL DEFAULT -1") - await self._ensure_column(conn, "config_hash", "TEXT NOT NULL DEFAULT ''") + await self._ensure_column( + conn, "mt_instance_id", "TEXT NOT NULL DEFAULT ''" + ) + await self._ensure_column( + conn, "chats_sync", "INTEGER NOT NULL DEFAULT -1" + ) + await self._ensure_column( + conn, "contacts_sync", "INTEGER NOT NULL DEFAULT -1" + ) + await self._ensure_column( + conn, "drafts_sync", "INTEGER NOT NULL DEFAULT -1" + ) + await self._ensure_column( + conn, "presence_sync", "INTEGER NOT NULL DEFAULT -1" + ) + await self._ensure_column( + conn, "config_hash", "TEXT NOT NULL DEFAULT ''" + ) await conn.execute( """ UPDATE sessions @@ -81,7 +93,9 @@ async def _ensure_column( columns = {row["name"] for row in await cursor.fetchall()} if name not in columns: - await conn.execute(f"ALTER TABLE sessions ADD COLUMN {name} {definition}") + await conn.execute( + f"ALTER TABLE sessions ADD COLUMN {name} {definition}" + ) async def save_session(self, session_info: SessionInfo) -> None: conn = await self._get_connection() @@ -144,7 +158,9 @@ async def load_session(self) -> SessionInfo | None: ) return self._row_to_session(row) - async def load_session_by_device_id(self, device_id: str) -> SessionInfo | None: + async def load_session_by_device_id( + self, device_id: str + ) -> SessionInfo | None: conn = await self._get_connection() logger.debug("loading session by device_id=%s", device_id) async with conn.execute( diff --git a/src/pymax/telemetry/navigation.py b/src/pymax/telemetry/navigation.py index 8f444f1..bec275b 100644 --- a/src/pymax/telemetry/navigation.py +++ b/src/pymax/telemetry/navigation.py @@ -155,7 +155,9 @@ def next_screen(self, profile: RouteProfile) -> Screen: self.current_screen = self.history.pop() return self.current_screen - next_screen = self._weighted_choice(self.rules.graph[self.current_screen]) + next_screen = self._weighted_choice( + self.rules.graph[self.current_screen] + ) if next_screen != self.current_screen: self.history.append(self.current_screen) if len(self.history) > 4: diff --git a/src/pymax/telemetry/service.py b/src/pymax/telemetry/service.py index 7d035e6..ddc3b7f 100644 --- a/src/pymax/telemetry/service.py +++ b/src/pymax/telemetry/service.py @@ -88,10 +88,14 @@ async def _run(self) -> None: while True: self._session_id += 1 - events = await self._collect_session_events(self._planner.new_profile()) + events = await self._collect_session_events( + self._planner.new_profile() + ) await self._send_events(events) self._planner.reset_to_background() - await asyncio.sleep(self._between(self._timing.session_idle_delay)) + await asyncio.sleep( + self._between(self._timing.session_idle_delay) + ) except asyncio.CancelledError: raise @@ -159,7 +163,9 @@ async def _send_events(self, events: list[TelemetryEvent]) -> None: except Exception: logger.debug("telemetry send failed", exc_info=True) - def _nav_event(self, screen_from: Screen, screen_to: Screen) -> TelemetryEvent: + def _nav_event( + self, screen_from: Screen, screen_to: Screen + ) -> TelemetryEvent: event = self._payloads.navigation( user_id=self._user_id, session_id=self._session_id, diff --git a/src/pymax/transport/tcp.py b/src/pymax/transport/tcp.py index 4c6fb6c..31373f7 100644 --- a/src/pymax/transport/tcp.py +++ b/src/pymax/transport/tcp.py @@ -10,7 +10,9 @@ class TCPTransport(Transport): - def __init__(self, host: str, port: int, proxy: str | None, use_ssl: bool = True) -> None: + def __init__( + self, host: str, port: int, proxy: str | None, use_ssl: bool = True + ) -> None: self._host = host self._port = port self._proxy = proxy @@ -57,10 +59,14 @@ async def connect(self) -> None: ) async def close(self) -> None: - if self._writer: + writer = self._writer + self._reader = None + self._writer = None + + if writer: logger.debug("tcp close") - self._writer.close() - await self._writer.wait_closed() + writer.close() + await writer.wait_closed() logger.debug("tcp closed") async def send(self, data: bytes | str) -> None: diff --git a/src/pymax/transport/websocket.py b/src/pymax/transport/websocket.py index 8fd52d9..cab0f29 100644 --- a/src/pymax/transport/websocket.py +++ b/src/pymax/transport/websocket.py @@ -1,5 +1,3 @@ -import asyncio - from websockets import ClientConnection, Origin from websockets.asyncio import client diff --git a/src/pymax/types/domain/__init__.py b/src/pymax/types/domain/__init__.py index 6c84885..965d06d 100644 --- a/src/pymax/types/domain/__init__.py +++ b/src/pymax/types/domain/__init__.py @@ -1,10 +1,13 @@ from .attachments import * +from .bots import InitData from .chat import Chat from .error import MaxApiError from .folder import Folder, FolderList, FolderUpdate from .login import LoginResponse +from .member import Member from .message import Message, ReactionCounter, ReactionInfo, ReadState from .name import Name +from .presence import Presence from .profile import Profile from .session import Session from .sync import SyncOverrides, SyncState diff --git a/src/pymax/types/domain/bots.py b/src/pymax/types/domain/bots.py new file mode 100644 index 0000000..9ec6cd7 --- /dev/null +++ b/src/pymax/types/domain/bots.py @@ -0,0 +1,14 @@ +from .base import CamelModel + + +class InitData(CamelModel): + """Начальные данные web app-бота. + + :ivar query_id: ID запроса инициализации. + :vartype query_id: str + :ivar url: URL для открытия web app-бота. + :vartype url: str + """ + + query_id: str + url: str diff --git a/src/pymax/types/domain/error.py b/src/pymax/types/domain/error.py index 05d8c2b..9b5e3e0 100644 --- a/src/pymax/types/domain/error.py +++ b/src/pymax/types/domain/error.py @@ -9,12 +9,12 @@ class MaxApiError(CamelModel): :ivar message: Сообщение ошибки. :vartype message: str :ivar title: Заголовок ошибки. - :vartype title: str + :vartype title: str | None :ivar localized_message: Локализованное сообщение ошибки. :vartype localized_message: str | None """ error: str message: str - title: str - localized_message: str | None + title: str | None = None + localized_message: str | None = None diff --git a/src/pymax/types/domain/folder.py b/src/pymax/types/domain/folder.py index 9fc7605..4a0b6ac 100644 --- a/src/pymax/types/domain/folder.py +++ b/src/pymax/types/domain/folder.py @@ -1,4 +1,4 @@ -from collections.abc import Generator, Iterator +from collections.abc import Iterator from typing import Any from pydantic import Field diff --git a/src/pymax/types/domain/login.py b/src/pymax/types/domain/login.py index 10a550a..35cef94 100644 --- a/src/pymax/types/domain/login.py +++ b/src/pymax/types/domain/login.py @@ -16,7 +16,9 @@ class LoginConfig(CamelModel): class LoginResponse(CamelModel): chats: list[Chat] = Field(default_factory=list) profile: Profile - messages: dict[int, list[Message]] = Field(default_factory=dict) # chat_id -> [message] + messages: dict[int, list[Message]] = Field( + default_factory=dict + ) # chat_id -> [message] contacts: list[User | None] = Field(default_factory=list) token: str time: int | None = None @@ -27,9 +29,19 @@ def update_sync_state(self, current: SyncState) -> SyncState: config_hash = self.config.hash if self.config is not None else None return SyncState( - chats_sync=(sync_time if sync_time is not None else current.chats_sync), - contacts_sync=(sync_time if sync_time is not None else current.contacts_sync), - drafts_sync=(sync_time if sync_time is not None else current.drafts_sync), - presence_sync=(sync_time if sync_time is not None else current.presence_sync), - config_hash=(config_hash if config_hash is not None else current.config_hash), + chats_sync=( + sync_time if sync_time is not None else current.chats_sync + ), + contacts_sync=( + sync_time if sync_time is not None else current.contacts_sync + ), + drafts_sync=( + sync_time if sync_time is not None else current.drafts_sync + ), + presence_sync=( + sync_time if sync_time is not None else current.presence_sync + ), + config_hash=( + config_hash if config_hash is not None else current.config_hash + ), ) diff --git a/src/pymax/types/domain/member.py b/src/pymax/types/domain/member.py new file mode 100644 index 0000000..25a0f95 --- /dev/null +++ b/src/pymax/types/domain/member.py @@ -0,0 +1,16 @@ +from .base import CamelModel +from .presence import Presence +from .user import User + + +class Member(CamelModel): + """Пользователь из списка участников или заявок на вступление. + + :ivar contact: Данные пользователя. + :vartype contact: User + :ivar presence: Информация о присутствии пользователя. + :vartype presence: Presence + """ + + contact: User + presence: Presence diff --git a/src/pymax/types/domain/presence.py b/src/pymax/types/domain/presence.py new file mode 100644 index 0000000..a3ef1b4 --- /dev/null +++ b/src/pymax/types/domain/presence.py @@ -0,0 +1,15 @@ +from .base import CamelModel + + +class Presence(CamelModel): + """Состояние присутствия пользователя. + + :ivar seen: Время последней активности в формате Unix time, если оно + передано сервером. + :vartype seen: int | None + :ivar status: Код статуса присутствия Max. + :vartype status: int + """ + + seen: int | None = None + status: int diff --git a/src/pymax/types/domain/sync.py b/src/pymax/types/domain/sync.py index e182109..57b4992 100644 --- a/src/pymax/types/domain/sync.py +++ b/src/pymax/types/domain/sync.py @@ -68,13 +68,29 @@ def resolve(self, saved: SyncState) -> SyncState: :rtype: SyncState """ return SyncState( - chats_sync=(self.chats_sync if self.chats_sync is not None else saved.chats_sync), + chats_sync=( + self.chats_sync + if self.chats_sync is not None + else saved.chats_sync + ), contacts_sync=( - self.contacts_sync if self.contacts_sync is not None else saved.contacts_sync + self.contacts_sync + if self.contacts_sync is not None + else saved.contacts_sync + ), + drafts_sync=( + self.drafts_sync + if self.drafts_sync is not None + else saved.drafts_sync ), - drafts_sync=(self.drafts_sync if self.drafts_sync is not None else saved.drafts_sync), presence_sync=( - self.presence_sync if self.presence_sync is not None else saved.presence_sync + self.presence_sync + if self.presence_sync is not None + else saved.presence_sync + ), + config_hash=( + self.config_hash + if self.config_hash is not None + else saved.config_hash ), - config_hash=(self.config_hash if self.config_hash is not None else saved.config_hash), ) diff --git a/src/pymax/types/domain/user.py b/src/pymax/types/domain/user.py index fdc25c6..5e58b60 100644 --- a/src/pymax/types/domain/user.py +++ b/src/pymax/types/domain/user.py @@ -18,6 +18,10 @@ class User(CamelModel): :vartype id: int :ivar account_status: Статус аккаунта в кодировке Max. :vartype account_status: int | None + :ivar registration_time: Время регистрации в формате Unix time. + :vartype registration_time: int | None + :ivar country: Код страны пользователя. + :vartype country: str | None :ivar base_raw_url: Исходный URL аватара. :vartype base_raw_url: str | None :ivar base_url: URL аватара. @@ -30,6 +34,10 @@ class User(CamelModel): :vartype photo_id: int | None :ivar update_time: Время обновления в формате Unix time. :vartype update_time: int | None + :ivar phone: Телефон пользователя, если возвращен API. + :vartype phone: int | None + :ivar status: Статус контакта, если возвращен API. + :vartype status: str | None :ivar description: Описание профиля. :vartype description: str | None :ivar gender: Пол пользователя. @@ -44,12 +52,16 @@ class User(CamelModel): id: int account_status: int | None = None + registration_time: int | None = None + country: str | None = None base_raw_url: str | None = None base_url: str | None = None names: list[Name] = Field(default_factory=list) options: list[str] = Field(default_factory=list) photo_id: int | None = None update_time: int | None = None + phone: int | None = None + status: str | None = None description: str | None = None gender: str | None = None link: str | None = None diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/api/test_auth_service.py b/tests/api/test_auth_service.py new file mode 100644 index 0000000..af33875 --- /dev/null +++ b/tests/api/test_auth_service.py @@ -0,0 +1,246 @@ +from __future__ import annotations + +import pytest + +from pymax.api.auth.enums import ProfileOptions, TwoFactorAction +from pymax.api.session.enums import DeviceType +from pymax.protocol import Opcode +from pymax.session.models import SessionInfo +from pymax.types.domain.sync import SyncState +from tests.conftest import FakeApp, frame, mobile_user_agent, profile_payload + + +class StaticEmailProvider: + def __init__(self, code: str = "123456") -> None: + self.code = code + self.emails: list[str] = [] + + async def get_code(self, email: str) -> str: + self.emails.append(email) + return self.code + + +@pytest.mark.asyncio +async def test_request_and_send_code_parse_auth_responses() -> None: + app = FakeApp( + [ + frame( + { + "token": "sms-token", + "codeLength": 6, + "requestMaxDuration": 60, + "requestCountLeft": 2, + "altActionDuration": 5, + } + ), + frame({"tokenAttrs": {"LOGIN": {"token": "login-token"}}}), + ] + ) + + start = await app.api.auth.request_code("+79990000000") + result = await app.api.auth.send_code(start.token, "111111") + + assert start.token == "sms-token" + assert result.login_token == "login-token" + assert [call.opcode for call in app.calls] == [ + Opcode.AUTH_REQUEST, + Opcode.AUTH, + ] + assert app.calls[0].payload["phone"] == "+79990000000" + assert app.calls[1].payload["verifyCode"] == "111111" + + +@pytest.mark.asyncio +async def test_mobile_login_sends_sync_payload_and_persists_updated_session() -> ( + None +): + app = FakeApp( + [ + frame( + { + "profile": profile_payload(42), + "token": "server-token", + "time": 777, + "config": {"hash": "cfg-hash"}, + } + ) + ] + ) + app.session = SessionInfo( + token="local-token", + device_id="device-test", + phone="+79990000000", + sync=SyncState( + chats_sync=1, contacts_sync=2, drafts_sync=3, presence_sync=4 + ), + ) + + response = await app.api.auth.mobile_login() + + assert response.token == "server-token" + assert app.calls[0].opcode == Opcode.LOGIN + assert app.calls[0].payload["token"] == "local-token" + assert ( + app.calls[0].payload["userAgent"]["deviceType"] == DeviceType.ANDROID + ) + assert app.session is not None + assert app.session.mt_instance_id == "mt-test" + assert app.session.sync.chats_sync == 777 + assert app.session.sync.contacts_sync == 777 + assert app.session.sync.drafts_sync == 777 + assert app.session.sync.presence_sync == 777 + assert app.session.sync.config_hash == "cfg-hash" + assert app.store.saved_sessions == [app.session] + + +@pytest.mark.asyncio +async def test_login_uses_web_payload_for_web_user_agent() -> None: + app = FakeApp( + [frame({"profile": profile_payload(42), "token": "web-token"})], + device_type=DeviceType.WEB, + ) + app.session = SessionInfo( + token="web-local-token", + device_id="device-test", + phone="", + ) + + response = await app.api.auth.login(mobile_user_agent(DeviceType.WEB)) + + assert response.token == "web-token" + assert app.calls[0].opcode == Opcode.LOGIN + assert app.calls[0].payload["token"] == "web-local-token" + assert "userAgent" not in app.calls[0].payload + assert app.calls[0].payload["chatsCount"] == 40 + + +@pytest.mark.asyncio +async def test_login_without_session_raises_runtime_error() -> None: + app = FakeApp() + + with pytest.raises(RuntimeError, match="No session available"): + await app.api.auth.mobile_login() + + assert app.calls == [] + + +@pytest.mark.asyncio +async def test_set_2fa_runs_password_email_hint_and_final_commit() -> None: + provider = StaticEmailProvider() + app = FakeApp( + [ + frame({"trackId": "track-1"}), + frame({}), + frame({}), + frame({}), + frame({}), + frame({}), + ] + ) + + result = await app.api.auth.set_2fa( + "secret", + email="user@example.com", + hint="hint", + email_code_provider=provider, + ) + + assert result is True + assert provider.emails == ["user@example.com"] + assert [call.opcode for call in app.calls] == [ + Opcode.AUTH_CREATE_TRACK, + Opcode.AUTH_VALIDATE_PASSWORD, + Opcode.AUTH_VERIFY_EMAIL, + Opcode.AUTH_CHECK_EMAIL, + Opcode.AUTH_VALIDATE_HINT, + Opcode.AUTH_SET_2FA, + ] + assert app.calls[-1].payload["trackId"] == "track-1" + assert app.calls[-1].payload["hint"] == "hint" + assert app.calls[-1].payload["expectedCapabilities"] == [ + TwoFactorAction.SET_PASSWORD, + TwoFactorAction.HINT, + TwoFactorAction.EMAIL, + ] + + +@pytest.mark.asyncio +async def test_set_2fa_requires_track_id() -> None: + app = FakeApp([frame({})]) + + with pytest.raises(RuntimeError, match="Failed to create auth track"): + await app.api.auth.set_2fa("secret") + + assert [call.opcode for call in app.calls] == [Opcode.AUTH_CREATE_TRACK] + + +@pytest.mark.asyncio +async def test_remove_2fa_checks_password_then_removes_factor() -> None: + app = FakeApp([frame({"trackId": "track-1"}), frame({}), frame({})]) + + assert await app.api.auth.remove_2fa("secret") is True + + assert [call.opcode for call in app.calls] == [ + Opcode.AUTH_CREATE_TRACK, + Opcode.AUTH_CHECK_PASSWORD, + Opcode.AUTH_SET_2FA, + ] + assert app.calls[2].payload["remove2fa"] is True + assert app.calls[2].payload["expectedCapabilities"] == [ + TwoFactorAction.REMOVE_2FA + ] + + +@pytest.mark.asyncio +async def test_qr_auth_service_methods_send_expected_payloads() -> None: + app = FakeApp( + [ + frame( + { + "expiresAt": 1000, + "pollingInterval": 100, + "qrLink": "max://qr", + "trackId": "track", + "ttl": 900, + } + ), + frame({"status": {"expiresAt": 1000, "loginAvailable": True}}), + frame({"tokenAttrs": {"LOGIN": {"token": "login-token"}}}), + frame({}), + ] + ) + + requested = await app.api.auth.request_qr() + checked = await app.api.auth.check_qr("track") + confirmed = await app.api.auth.confirm_qr("track") + approved = await app.api.auth.authorize_qr_login("max://qr") + + assert requested.track_id == "track" + assert checked.status.login_available is True + assert confirmed.login_token == "login-token" + assert approved is True + assert [call.opcode for call in app.calls] == [ + Opcode.GET_QR, + Opcode.GET_QR_STATUS, + Opcode.LOGIN_BY_QR, + Opcode.AUTH_QR_APPROVE, + ] + assert app.calls[1].payload == {"trackId": "track"} + assert app.calls[3].payload == {"qrLink": "max://qr"} + + +@pytest.mark.asyncio +async def test_check_2fa_reads_profile_options() -> None: + app = FakeApp() + + assert await app.api.auth.check_2fa() is False + + app.me = None + assert await app.api.auth.check_2fa() is False + + from pymax.types.domain import Profile + + app.me = Profile.model_validate( + profile_payload(1, [ProfileOptions.SECOND_FACTOR_PASSWORD_ENABLED]) + ) + assert await app.api.auth.check_2fa() is True diff --git a/tests/api/test_chat_user_self_session_services.py b/tests/api/test_chat_user_self_session_services.py new file mode 100644 index 0000000..f93dd5d --- /dev/null +++ b/tests/api/test_chat_user_self_session_services.py @@ -0,0 +1,397 @@ +from __future__ import annotations + +import pytest + +from pymax.api.session.enums import DeviceType +from pymax.exceptions import PyMaxError +from pymax.protocol import Opcode +from pymax.session.models import SessionInfo +from tests.conftest import ( + FakeApp, + chat_payload, + frame, + member_payload, + message_payload, + profile_payload, + user_payload, +) + + +def test_base_mixin_exposes_chat_join_request_and_bot_methods() -> None: + from pymax.infra import BaseMixin + + for method_name in ( + "get_join_requests", + "confirm_join_requests", + "confirm_join_request", + "decline_join_requests", + "decline_join_request", + "get_bot_init_data", + "change_password", + ): + assert hasattr(BaseMixin, method_name) + + +@pytest.mark.asyncio +async def test_get_chats_uses_cache_fetches_misses_and_preserves_order() -> ( + None +): + app = FakeApp([frame({"chats": [chat_payload(2), chat_payload(3)]})]) + from pymax.types.domain import Chat + + app.chats = [ + Chat.model_validate(chat_payload(1)).bind( + app.api.messages, app.api.chats + ) + ] + + chats = await app.api.chats.get_chats([1, 2, 3]) + + assert [chat.id for chat in chats] == [1, 2, 3] + assert app.calls[0].opcode == Opcode.CHAT_INFO + assert app.calls[0].payload["chatIds"] == [2, 3] + + +@pytest.mark.asyncio +async def test_get_chat_raises_when_response_does_not_contain_chat() -> None: + app = FakeApp([frame({"chats": []})]) + + with pytest.raises(PyMaxError, match="Chat not found"): + await app.api.chats.get_chat(999) + + +@pytest.mark.asyncio +async def test_join_group_validates_link_and_caches_joined_chat() -> None: + app = FakeApp([frame({"chat": chat_payload(10)})]) + + with pytest.raises(ValueError, match="Invalid group link"): + await app.api.chats.join_group("https://example.com/nope") + + chat = await app.api.chats.join_group("https://max.ru/join/abc") + + assert chat.id == 10 + assert app.chats == [chat] + assert app.calls[0].opcode == Opcode.CHAT_JOIN + assert app.calls[0].payload["link"] == "join/abc" + + +@pytest.mark.asyncio +async def test_create_group_returns_chat_and_message_and_updates_cache() -> ( + None +): + app = FakeApp( + [frame({**message_payload(7, 10), "chat": chat_payload(10)})] + ) + + result = await app.api.chats.create_group("Team", [1, 2], notify=False) + + assert result is not None + chat, message = result + assert chat.id == 10 + assert message.id == 7 + assert app.chats == [chat] + assert app.calls[0].opcode == Opcode.MSG_SEND + assert app.calls[0].payload["message"]["attaches"][0]["title"] == "Team" + assert app.calls[0].payload["notify"] is False + + +@pytest.mark.asyncio +async def test_leave_group_removes_cached_chat() -> None: + from pymax.types.domain import Chat + + app = FakeApp([frame({})]) + app.chats = [ + Chat.model_validate(chat_payload(10)).bind( + app.api.messages, app.api.chats + ), + Chat.model_validate(chat_payload(11)).bind( + app.api.messages, app.api.chats + ), + ] + + await app.api.chats.leave_group(10) + + assert [chat.id for chat in app.chats or []] == [11] + assert app.calls[0].opcode == Opcode.CHAT_LEAVE + + +@pytest.mark.asyncio +async def test_group_mutation_methods_update_cache_and_parse_optional_chats() -> ( + None +): + app = FakeApp( + [ + frame({"chat": chat_payload(10, "CHAT")}), + frame({"chat": chat_payload(10, "CHAT")}), + frame({"chat": chat_payload(10, "CHAT")}), + frame({"chat": chat_payload(10, "CHAT")}), + frame({"chat": chat_payload(10, "CHAT")}), + frame({"chat": chat_payload(10, "CHAT")}), + frame({"chat": chat_payload(10, "CHAT")}), + frame({"chats": [chat_payload(20, "CHANNEL")]}), + ] + ) + + assert await app.api.chats.invite_users_to_group(10, [1, 2]) is not None + assert await app.api.chats.invite_users_to_channel(10, [3]) is not None + assert await app.api.chats.remove_users_from_group( + 10, [2], clean_msg_period=0 + ) + await app.api.chats.change_group_settings(10, all_can_pin_message=True) + await app.api.chats.change_group_profile(10, "New title", "Description") + resolved = await app.api.chats.resolve_group_by_link( + "https://max.ru/join/abc" + ) + reworked = await app.api.chats.rework_invite_link(10) + fetched = await app.api.chats.fetch_chats(marker=123) + + assert resolved is not None + assert reworked.id == 10 + assert [chat.id for chat in fetched] == [20] + assert [call.opcode for call in app.calls] == [ + Opcode.CHAT_MEMBERS_UPDATE, + Opcode.CHAT_MEMBERS_UPDATE, + Opcode.CHAT_MEMBERS_UPDATE, + Opcode.CHAT_UPDATE, + Opcode.CHAT_UPDATE, + Opcode.LINK_INFO, + Opcode.CHAT_UPDATE, + Opcode.CHATS_LIST, + ] + assert app.calls[3].payload["options"]["ALL_CAN_PIN_MESSAGE"] is True + assert app.calls[4].payload["theme"] == "New title" + assert app.calls[-1].payload["marker"] == 123 + + +@pytest.mark.asyncio +async def test_join_request_methods_fetch_confirm_decline_and_update_cache() -> ( + None +): + app = FakeApp( + [ + frame({"members": [member_payload(2)]}), + frame({"chat": chat_payload(10, "CHAT")}), + frame({"chat": chat_payload(10, "CHAT")}), + frame({"chat": chat_payload(10, "CHAT")}), + ] + ) + + requests = await app.api.chats.get_join_requests(10, count=5) + confirmed = await app.api.chats.confirm_join_requests( + 10, + [2, 3], + show_history=False, + ) + confirmed_one = await app.api.chats.confirm_join_request(10, 4) + declined = await app.api.chats.decline_join_request(10, 5) + + assert [request.contact.id for request in requests] == [2] + assert confirmed is not None + assert confirmed_one is not None + assert declined is not None + assert [chat.id for chat in app.chats or []] == [10] + assert [call.opcode for call in app.calls] == [ + Opcode.CHAT_MEMBERS, + Opcode.CHAT_MEMBERS_UPDATE, + Opcode.CHAT_MEMBERS_UPDATE, + Opcode.CHAT_MEMBERS_UPDATE, + ] + assert app.calls[0].payload == { + "chatId": 10, + "type": "JOIN_REQUEST", + "count": 5, + } + assert app.calls[1].payload["operation"] == "add" + assert app.calls[1].payload["userIds"] == [2, 3] + assert app.calls[1].payload["showHistory"] is False + assert app.calls[2].payload["operation"] == "add" + assert app.calls[2].payload["userIds"] == [4] + assert app.calls[3].payload["operation"] == "remove" + assert app.calls[3].payload["userIds"] == [5] + assert "showHistory" not in app.calls[3].payload + + +@pytest.mark.asyncio +async def test_user_service_fetches_caches_searches_and_removes_contacts() -> ( + None +): + app = FakeApp( + [ + frame({"contacts": [user_payload(2), user_payload(3)]}), + frame({"contact": user_payload(4)}), + frame({"contact": user_payload(2)}), + ] + ) + app.users[1] = __import__( + "pymax.types.domain", fromlist=["User"] + ).User.model_validate(user_payload(1)) + + users = await app.api.users.get_users([1, 2, 3]) + found = await app.api.users.search_by_phone("+79990000004") + removed = await app.api.users.remove_contact(2) + + assert [user.id for user in users] == [1, 2, 3] + assert found.id == 4 + assert removed is True + assert 2 not in app.users + assert [call.opcode for call in app.calls] == [ + Opcode.CONTACT_INFO, + Opcode.CONTACT_INFO_BY_PHONE, + Opcode.CONTACT_UPDATE, + ] + + +@pytest.mark.asyncio +async def test_user_service_get_user_add_contact_sessions_and_chat_id() -> ( + None +): + app = FakeApp( + [ + frame({"contacts": [user_payload(5)]}), + frame({"contact": user_payload(6)}), + frame({"sessions": [{"id": "s1", "deviceId": "device"}]}), + ] + ) + + user = await app.api.users.get_user(5) + added = await app.api.users.add_contact(6) + sessions = await app.api.users.get_sessions() + + assert user is not None + assert user.id == 5 + assert added.id == 6 + assert sessions[0].device_id == "device" + assert app.api.users.get_chat_id(10, 3) == 9 + assert [call.opcode for call in app.calls] == [ + Opcode.CONTACT_INFO, + Opcode.CONTACT_UPDATE, + Opcode.SESSIONS_INFO, + ] + + +@pytest.mark.asyncio +async def test_self_service_change_profile_and_close_all_sessions() -> None: + app = FakeApp( + [ + frame({"profile": profile_payload(9)}), + frame({"token": "new-token"}), + ] + ) + app.session = SessionInfo(token="old-token", device_id="dev", phone="+7") + + assert ( + await app.api.account.change_profile("Ink", description="hello") + is True + ) + assert app.me is not None + assert app.me.contact.id == 9 + assert app.users[9].id == 9 + + assert await app.api.account.close_all_sessions() is True + assert app.store.updated_tokens == [("old-token", "new-token")] + assert [call.opcode for call in app.calls] == [ + Opcode.PROFILE, + Opcode.SESSIONS_CLOSE, + ] + + +@pytest.mark.asyncio +async def test_close_all_sessions_returns_false_without_session_or_token() -> ( + None +): + app = FakeApp() + assert await app.api.account.close_all_sessions() is False + + app.session = SessionInfo(token="old-token", device_id="dev", phone="+7") + app.responses.append(frame({})) + assert await app.api.account.close_all_sessions() is False + assert app.store.updated_tokens == [] + + +@pytest.mark.asyncio +async def test_self_service_profile_photo_folders_and_logout() -> None: + app = FakeApp( + [ + frame({"url": "https://upload.profile"}), + frame( + { + "folder": {"id": "folder-1", "title": "Work"}, + "folderSync": 1, + } + ), + frame( + { + "folders": [{"id": "folder-1", "title": "Work"}], + "folderSync": 2, + } + ), + frame( + {"folder": {"id": "folder-1", "title": "New"}, "folderSync": 3} + ), + frame({"foldersOrder": [], "folderSync": 4}), + frame({}), + ] + ) + + assert ( + await app.api.account.request_profile_photo_upload_url() + == "https://upload.profile" + ) + created = await app.api.account.create_folder("Work", [10]) + folders = await app.api.account.get_folders(folder_sync=1) + updated = await app.api.account.update_folder("folder-1", "New", [11]) + deleted = await app.api.account.delete_folder("folder-1") + assert await app.api.account.logout() is True + + assert created.folder is not None + assert created.folder.title == "Work" + assert [folder.id for folder in folders] == ["folder-1"] + assert updated.folder is not None + assert updated.folder.title == "New" + assert deleted.folder_sync == 4 + assert [call.opcode for call in app.calls] == [ + Opcode.PHOTO_UPLOAD, + Opcode.FOLDERS_UPDATE, + Opcode.FOLDERS_GET, + Opcode.FOLDERS_UPDATE, + Opcode.FOLDERS_DELETE, + Opcode.LOGOUT, + ] + + +@pytest.mark.asyncio +async def test_session_handshake_switches_between_mobile_and_web_payloads() -> ( + None +): + mobile_app = FakeApp([frame({})]) + await mobile_app.api.session.handshake( + "mt", + mobile_app.config.device.user_agent, + "device", + ) + + web_app = FakeApp([frame({})], device_type=DeviceType.WEB) + await web_app.api.session.handshake( + "ignored", + web_app.config.device.user_agent, + "web-device", + ) + + assert mobile_app.calls[0].opcode == Opcode.SESSION_INIT + assert mobile_app.calls[0].payload["mt_instanceid"] == "mt" + assert web_app.calls[0].payload["deviceId"] == "web-device" + assert ( + web_app.calls[0].payload["userAgent"]["deviceType"] == DeviceType.WEB + ) + assert "mt_instanceid" not in web_app.calls[0].payload + + +@pytest.mark.asyncio +async def test_bots_service_parses_init_data() -> None: + app = FakeApp([frame({"queryId": "query", "url": "https://app"})]) + + init_data = await app.api.bots.get_init_data(1, 2, start_param="x") + + assert init_data.query_id == "query" + assert app.calls[0].opcode == Opcode.WEB_APP_INIT_DATA + assert app.calls[0].payload == {"botId": 1, "chatId": 2, "startParam": "x"} diff --git a/tests/api/test_message_service.py b/tests/api/test_message_service.py new file mode 100644 index 0000000..f95f54b --- /dev/null +++ b/tests/api/test_message_service.py @@ -0,0 +1,209 @@ +from __future__ import annotations + +import pytest + +from pymax.api.messages.enums import ItemType, MessagePayloadKey +from pymax.api.uploads.payloads import AttachPhotoPayload +from pymax.exceptions import UploadError +from pymax.files import File, Photo +from pymax.protocol import Opcode +from tests.conftest import FakeApp, frame, message_payload + + +@pytest.mark.asyncio +async def test_send_message_formats_markdown_uploads_attachments_and_binds_result( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr("pymax.api.messages.service.time.time", lambda: 1000.0) + app = FakeApp([frame(message_payload(55, 100, "Hello bold"))]) + photo = Photo(raw=b"image", name="image.jpg") + + result = await app.api.messages.send_message( + 100, + "Hello **bold**", + reply_to=44, + attachments=[photo], + notify=False, + ) + + assert result is not None + assert result.id == 55 + assert result._actions is app.api.messages + assert app.api.uploads.calls == [("photo", photo)] + assert app.calls[0].opcode == Opcode.MSG_SEND + sent = app.calls[0].payload + assert sent["chatId"] == 100 + assert sent["notify"] is False + assert sent["message"]["text"] == "Hello bold" + assert sent["message"]["cid"] == 1000001 + assert sent["message"]["link"] == {"type": "REPLY", "messageId": 44} + assert sent["message"]["elements"][0]["type"] == "STRONG" + assert sent["message"]["attaches"][0]["photoToken"] == "photo-token" + + +@pytest.mark.asyncio +async def test_send_message_raises_when_attachment_upload_fails() -> None: + app = FakeApp() + app.api.uploads.photo_result = None + + with pytest.raises(UploadError, match="Photo uploading failed"): + await app.api.messages.send_message( + 100, + "photo", + attachments=[Photo(raw=b"image", name="image.jpg")], + ) + + assert app.calls == [] + + +@pytest.mark.asyncio +async def test_upload_attachments_handles_file_video_and_empty_lists() -> None: + app = FakeApp() + assert await app.api.messages._upload_attachments(None) == [] + + result = await app.api.messages._upload_attachments( + [File(raw=b"abc", name="doc.txt")] + ) + + assert result[0].file_id == 30 + assert app.api.uploads.calls[0][0] == "file" + + +@pytest.mark.asyncio +async def test_fetch_history_builds_payload_and_parses_messages( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr("pymax.api.messages.service.time.time", lambda: 2000.0) + app = FakeApp( + [ + frame( + { + MessagePayloadKey.MESSAGES.value: [ + message_payload(1, 100, "one"), + message_payload(2, 100, "two"), + ] + } + ) + ] + ) + + messages = await app.api.messages.fetch_history( + 100, + backward=2, + from_=123, + item_type=ItemType.DELAYED, + get_chat=True, + interactive=True, + ) + + assert [message.id for message in messages or []] == [1, 2] + assert app.calls[0].opcode == Opcode.CHAT_HISTORY + assert app.calls[0].payload["from"] == 123 + assert app.calls[0].payload["itemType"] == ItemType.DELAYED + assert app.calls[0].payload["getChat"] is True + + +@pytest.mark.asyncio +async def test_delete_pin_and_read_message_send_expected_opcodes( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr("pymax.api.messages.service.time.time", lambda: 3000.0) + app = FakeApp( + [frame({}), frame({}), frame({"unread": 0, "mark": 3000000})] + ) + + assert ( + await app.api.messages.delete_message(100, [1, 2], for_me=True) is True + ) + assert await app.api.messages.pin_message(100, 2, notify_pin=False) is True + read_state = await app.api.messages.read_message(2, 100) + + assert read_state.mark == 3000000 + assert [call.opcode for call in app.calls] == [ + Opcode.MSG_DELETE, + Opcode.CHAT_UPDATE, + Opcode.CHAT_MARK, + ] + assert app.calls[0].payload["forMe"] is True + assert app.calls[1].payload["pinMessageId"] == 2 + assert app.calls[2].payload["messageId"] == "2" + + +@pytest.mark.asyncio +async def test_reaction_methods_parse_optional_reaction_info() -> None: + reaction_info = { + "totalCount": 1, + "counters": [{"count": 1, "reaction": "👍"}], + } + app = FakeApp( + [ + frame({MessagePayloadKey.REACTION_INFO.value: reaction_info}), + frame( + { + MessagePayloadKey.MESSAGES_REACTIONS.value: { + "10": reaction_info, + "11": {"totalCount": 0, "counters": []}, + } + } + ), + frame({}), + ] + ) + + added = await app.api.messages.add_reaction(100, "10", "👍") + reactions = await app.api.messages.get_reactions(100, ["10", "11"]) + removed = await app.api.messages.remove_reaction(100, "10") + + assert added is not None + assert added.total_count == 1 + assert reactions is not None + assert reactions["10"].counters[0].reaction == "👍" + assert removed is None + assert [call.opcode for call in app.calls] == [ + Opcode.MSG_REACTION, + Opcode.MSG_GET_REACTIONS, + Opcode.MSG_CANCEL_REACTION, + ] + + +@pytest.mark.asyncio +async def test_get_video_and_file_by_id_parse_request_models() -> None: + app = FakeApp( + [ + frame({"cache": True, "dynamicUrl": "https://video.test"}), + frame({"unsafe": False, "url": "https://file.test"}), + ] + ) + + video = await app.api.messages.get_video_by_id(100, 10, 20) + file = await app.api.messages.get_file_by_id(100, "10", 30) + + assert video is not None + assert video.url == "https://video.test" + assert file is not None + assert file.url == "https://file.test" + assert [call.opcode for call in app.calls] == [ + Opcode.VIDEO_PLAY, + Opcode.FILE_DOWNLOAD, + ] + assert app.calls[0].payload == { + "chatId": 100, + "messageId": 10, + "videoId": 20, + } + assert app.calls[1].payload == { + "chatId": 100, + "messageId": "10", + "fileId": 30, + } + + +def test_next_cid_is_monotonic_when_clock_does_not_move( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr("pymax.api.messages.service.time.time", lambda: 10.0) + app = FakeApp() + app.api.messages._prev = 10000 + + assert app.api.messages._next_cid() == 10001 + assert app.api.messages._next_cid() == 10002 diff --git a/tests/api/test_upload_service.py b/tests/api/test_upload_service.py new file mode 100644 index 0000000..3485dab --- /dev/null +++ b/tests/api/test_upload_service.py @@ -0,0 +1,188 @@ +from __future__ import annotations + +import pytest + +from pymax.api.uploads.service import UploadService +from pymax.files import File, Photo, Video +from pymax.protocol import Opcode +from pymax.types.events import FileUploadSignal, VideoUploadSignal +from tests.conftest import FakeApp, frame + + +class FakeHttpResponse: + def __init__( + self, status: int, json_data: dict | None = None, on_enter=None + ) -> None: + self.status = status + self.json_data = json_data or {} + self.on_enter = on_enter + + async def __aenter__(self): + if self.on_enter is not None: + self.on_enter() + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def json(self) -> dict: + return self.json_data + + +class FakeHttpSession: + posts: list[dict] = [] + response = FakeHttpResponse( + 200, {"photos": {"photo-1": {"token": "uploaded"}}} + ) + + def __init__(self, *args, **kwargs) -> None: + self.args = args + self.kwargs = kwargs + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + def post(self, **kwargs): + self.posts.append(kwargs) + return self.response + + +@pytest.mark.asyncio +async def test_upload_photo_requests_url_posts_file_and_returns_attach_payload( + monkeypatch: pytest.MonkeyPatch, +) -> None: + app = FakeApp( + [frame({"url": "https://upload.test/path?photoIds=photo-1"})] + ) + service = UploadService(app) + monkeypatch.setattr( + "pymax.api.uploads.service.aiohttp.ClientSession", + FakeHttpSession, + ) + FakeHttpSession.posts = [] + FakeHttpSession.response = FakeHttpResponse( + 200, + {"photos": {"photo-1": {"token": "uploaded"}}}, + ) + + result = await service.upload_photo( + Photo(raw=b"image-bytes", name="image.jpg") + ) + + assert result.photo_token == "uploaded" + assert app.calls[0].opcode == Opcode.PHOTO_UPLOAD + assert ( + FakeHttpSession.posts[0]["url"] + == "https://upload.test/path?photoIds=photo-1" + ) + + +@pytest.mark.asyncio +async def test_upload_waiters_resolve_video_and_file_processing_signals() -> ( + None +): + app = FakeApp() + service = UploadService(app) + loop = __import__("asyncio").get_running_loop() + video_future = loop.create_future() + file_future = loop.create_future() + service.video_upload_waiters[1] = video_future + service.file_upload_waiters[2] = file_future + + await service.on_video_attach(VideoUploadSignal(video_id=1), None) + await service.on_file_attach(FileUploadSignal(file_id=2), None) + + assert video_future.result().video_id == 1 + assert file_future.result().file_id == 2 + assert service.video_upload_waiters == {} + assert service.file_upload_waiters == {} + + +@pytest.mark.asyncio +async def test_upload_video_posts_chunks_waits_for_processing_and_cleans_waiter( + monkeypatch: pytest.MonkeyPatch, +) -> None: + app = FakeApp( + [ + frame( + { + "info": [ + { + "url": "https://upload.test/video", + "videoId": 10, + "token": "video-token", + } + ] + } + ) + ] + ) + service = UploadService(app) + + def resolve_processing() -> None: + service.video_upload_waiters[10].set_result( + VideoUploadSignal(video_id=10) + ) + + FakeHttpSession.posts = [] + FakeHttpSession.response = FakeHttpResponse( + 200, on_enter=resolve_processing + ) + monkeypatch.setattr( + "pymax.api.uploads.service.aiohttp.ClientSession", + FakeHttpSession, + ) + + result = await service.upload_video(Video(raw=b"video", name="clip.mp4")) + + assert result.video_id == 10 + assert result.token == "video-token" + assert service.video_upload_waiters == {} + assert FakeHttpSession.posts[0]["headers"]["Content-Range"] == "0-4/5" + assert FakeHttpSession.posts[0]["url"] == "https://upload.test/video" + + +@pytest.mark.asyncio +async def test_upload_file_posts_chunks_waits_for_processing_and_cleans_waiter( + monkeypatch: pytest.MonkeyPatch, +) -> None: + app = FakeApp( + [ + frame( + { + "info": [ + { + "url": "https://upload.test/file", + "fileId": 11, + "token": "file-token", + } + ] + } + ) + ] + ) + service = UploadService(app) + + def resolve_processing() -> None: + service.file_upload_waiters[11].set_result( + FileUploadSignal(file_id=11) + ) + + FakeHttpSession.posts = [] + FakeHttpSession.response = FakeHttpResponse( + 200, on_enter=resolve_processing + ) + monkeypatch.setattr( + "pymax.api.uploads.service.aiohttp.ClientSession", + FakeHttpSession, + ) + + result = await service.upload_file(File(raw=b"file", name="doc.txt")) + + assert result.file_id == 11 + assert service.file_upload_waiters == {} + assert FakeHttpSession.posts[0]["headers"]["Content-Range"] == "0-3/4" + assert FakeHttpSession.posts[0]["url"] == "https://upload.test/file" diff --git a/tests/app/test_app_runtime.py b/tests/app/test_app_runtime.py new file mode 100644 index 0000000..56d4b07 --- /dev/null +++ b/tests/app/test_app_runtime.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +import asyncio +from collections import deque +from types import SimpleNamespace + +import pytest + +from pymax.app import App +from pymax.auth.models import AuthResult +from pymax.exceptions import ApiError +from pymax.protocol import Command, InboundFrame, Opcode +from pymax.session.models import SessionInfo +from tests.conftest import frame, make_config, profile_payload + + +class RuntimeStore: + def __init__(self, loaded: SessionInfo | None = None) -> None: + self.loaded = loaded + self.saved: list[SessionInfo] = [] + self.closed = False + + async def load_session(self) -> SessionInfo | None: + return self.loaded + + async def save_session(self, session: SessionInfo) -> None: + self.saved.append(session) + self.loaded = session + + async def update_token(self, old_token: str, new_token: str) -> None: + if self.loaded and self.loaded.token == old_token: + self.loaded = self.loaded.model_copy(update={"token": new_token}) + + async def close(self) -> None: + self.closed = True + + +class RuntimeConnection: + def __init__(self, responses: list[InboundFrame]) -> None: + self.responses = deque(responses) + self.protocol = SimpleNamespace(version=10) + self.sent = [] + self.opened = False + self.closed = False + self.failed = None + self.on_event = None + self._seq = -1 + + async def open(self) -> None: + self.opened = True + + async def close(self) -> None: + self.closed = True + self.opened = False + + async def fail(self, exc: Exception | None = None) -> None: + self.failed = exc + + async def request(self, outbound, timeout=None): + self.sent.append((outbound, timeout)) + return self.responses.popleft() + + async def wait_closed(self) -> None: + return None + + def next_seq(self) -> int: + self._seq += 1 + return self._seq + + @property + def is_open(self) -> bool: + return self.opened + + +class StaticAuthFlow: + async def authenticate(self, app: App) -> AuthResult: + return AuthResult(token="auth-token") + + +@pytest.mark.asyncio +async def test_app_start_with_config_token_handshakes_logs_in_and_saves_session( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def idle_ping_loop(self): + await asyncio.Event().wait() + + monkeypatch.setattr(App, "_ping_loop", idle_ping_loop) + store = RuntimeStore() + config = make_config().model_copy( + update={"token": "config-token", "store": store} + ) + connection = RuntimeConnection( + [ + frame({}), + frame( + { + "profile": profile_payload(77), + "token": "login-token", + "contacts": [profile_payload(77)["contact"]], + "chats": [], + "messages": {}, + } + ), + ] + ) + app: App[object] = App(connection, config, StaticAuthFlow()) + + await app.start() + + assert app.started is True + assert app.me is not None + assert app.me.contact.id == 77 + assert store.saved[0].token == "config-token" + assert [sent[0].opcode for sent in connection.sent] == [ + Opcode.SESSION_INIT, + Opcode.LOGIN, + ] + + await app.close() + assert connection.closed is True + assert store.closed is True + + +@pytest.mark.asyncio +async def test_app_invoke_turns_error_frames_into_api_error() -> None: + store = RuntimeStore( + SessionInfo(token="token", device_id="dev", phone="+7") + ) + config = make_config().model_copy(update={"store": store}) + connection = RuntimeConnection( + [ + InboundFrame( + opcode=Opcode.PING, + cmd=Command.ERROR, + seq=1, + payload={ + "error": "rate_limited", + "title": "Slow down", + "message": "Too many requests", + "localizedMessage": "Too many requests", + }, + ) + ] + ) + app: App[object] = App(connection, config, StaticAuthFlow()) + + with pytest.raises(ApiError) as exc_info: + await app.invoke(Opcode.PING, {"interactive": True}) + + assert exc_info.value.error == "rate_limited" + assert exc_info.value.opcode == Opcode.PING + assert connection.sent[0][0].seq == 0 diff --git a/tests/auth/test_auth_flows.py b/tests/auth/test_auth_flows.py new file mode 100644 index 0000000..96e81dc --- /dev/null +++ b/tests/auth/test_auth_flows.py @@ -0,0 +1,161 @@ +from __future__ import annotations + +import time +from types import SimpleNamespace + +import pytest + +from pymax.auth.qr import QrAuthFlow +from pymax.auth.sms import SmsAuthFlow +from pymax.types.domain.auth import ( + CheckCodeResponse, + CheckPasswordResponse, + CheckQrResponse, + RequestQrResponse, + StartAuthResponse, +) + + +class StaticCodeProvider: + def __init__(self, code: str = "111111") -> None: + self.code = code + self.phones: list[str] = [] + + async def get_code(self, phone: str) -> str: + self.phones.append(phone) + return self.code + + +class SequencePasswordProvider: + def __init__(self, passwords: list[str]) -> None: + self.passwords = list(passwords) + self.hints: list[str | None] = [] + + async def get_password(self, hint: str | None = None) -> str: + self.hints.append(hint) + return self.passwords.pop(0) + + +class SmsAuthApi: + def __init__(self) -> None: + self.calls: list[tuple[str, tuple]] = [] + + async def request_code(self, phone: str): + self.calls.append(("request_code", (phone,))) + return StartAuthResponse.model_validate( + { + "token": "sms-token", + "codeLength": 6, + "requestMaxDuration": 60, + "requestCountLeft": 1, + "altActionDuration": 0, + } + ) + + async def send_code(self, token: str, code: str): + self.calls.append(("send_code", (token, code))) + return CheckCodeResponse.model_validate( + {"passwordChallenge": {"trackId": "track-1", "hint": "hint"}} + ) + + async def check_password(self, track_id: str, password: str): + self.calls.append(("check_password", (track_id, password))) + return CheckPasswordResponse.model_validate( + {"tokenAttrs": {"LOGIN": {"token": "2fa-token"}}} + ) + + +@pytest.mark.asyncio +async def test_sms_auth_flow_requests_code_and_completes_2fa() -> None: + auth_api = SmsAuthApi() + app = SimpleNamespace( + config=SimpleNamespace(phone="+79990000000"), + api=SimpleNamespace(auth=auth_api), + ) + code_provider = StaticCodeProvider() + password_provider = SequencePasswordProvider(["", "secret"]) + flow = SmsAuthFlow(code_provider, password_provider) + + result = await flow.authenticate(app) + + assert result.token == "2fa-token" + assert code_provider.phones == ["+79990000000"] + assert password_provider.hints == ["hint", "hint"] + assert auth_api.calls == [ + ("request_code", ("+79990000000",)), + ("send_code", ("sms-token", "111111")), + ("check_password", ("track-1", "secret")), + ] + + +@pytest.mark.asyncio +async def test_sms_auth_flow_requires_phone() -> None: + flow = SmsAuthFlow(StaticCodeProvider()) + app = SimpleNamespace(config=SimpleNamespace(phone=None)) + + with pytest.raises(RuntimeError, match="Phone is required"): + await flow.authenticate(app) + + +class QrProvider: + def __init__(self) -> None: + self.links: list[str] = [] + + async def show_qr(self, qr_url: str) -> None: + self.links.append(qr_url) + + +class QrAuthApi: + def __init__(self) -> None: + self.statuses = [False, True] + self.calls: list[tuple[str, tuple]] = [] + + async def request_qr(self): + self.calls.append(("request_qr", ())) + return RequestQrResponse.model_validate( + { + "expiresAt": int((time.time() + 5) * 1000), + "pollingInterval": 0, + "qrLink": "max://qr", + "trackId": "qr-track", + "ttl": 5000, + } + ) + + async def check_qr(self, track_id: str): + self.calls.append(("check_qr", (track_id,))) + return CheckQrResponse.model_validate( + { + "status": { + "expiresAt": int((time.time() + 5) * 1000), + "loginAvailable": self.statuses.pop(0), + } + } + ) + + async def confirm_qr(self, track_id: str): + self.calls.append(("confirm_qr", (track_id,))) + return CheckCodeResponse.model_validate( + {"tokenAttrs": {"LOGIN": {"token": "qr-token"}}} + ) + + +@pytest.mark.asyncio +async def test_qr_auth_flow_shows_qr_polls_until_available_and_confirms() -> ( + None +): + provider = QrProvider() + auth_api = QrAuthApi() + app = SimpleNamespace(api=SimpleNamespace(auth=auth_api)) + flow = QrAuthFlow(provider) + + result = await flow.authenticate(app) + + assert result.token == "qr-token" + assert provider.links == ["max://qr"] + assert auth_api.calls == [ + ("request_qr", ()), + ("check_qr", ("qr-track",)), + ("check_qr", ("qr-track",)), + ("confirm_qr", ("qr-track",)), + ] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..f53778e --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,255 @@ +from __future__ import annotations + +from dataclasses import dataclass +from types import SimpleNamespace +from typing import Any + +import pytest + +from pymax.api.auth.service import AuthService +from pymax.api.bots.service import BotsService +from pymax.api.chats.service import ChatService +from pymax.api.messages.service import MessageService +from pymax.api.self.service import SelfService +from pymax.api.session.enums import DeviceType +from pymax.api.session.payloads import MobileUserAgentPayload +from pymax.api.session.service import SessionService +from pymax.api.uploads.payloads import ( + AttachFilePayload, + AttachPhotoPayload, + VideoAttachPayload, +) +from pymax.api.users.service import UserService +from pymax.config import ClientConfig, DeviceConfig +from pymax.protocol import Command, InboundFrame +from pymax.session.models import SessionInfo +from pymax.types.domain.sync import SyncOverrides + + +@dataclass +class InvokeCall: + opcode: int + payload: dict[str, Any] + cmd: int + timeout: float | None + compress: bool + + +class FakeStore: + def __init__(self) -> None: + self.saved_sessions: list[SessionInfo] = [] + self.updated_tokens: list[tuple[str, str]] = [] + self.closed = False + + async def save_session(self, session: SessionInfo) -> None: + self.saved_sessions.append(session) + + async def update_token(self, old_token: str, new_token: str) -> None: + self.updated_tokens.append((old_token, new_token)) + + async def close(self) -> None: + self.closed = True + + +class FakeDispatcher: + def __init__(self) -> None: + self.internal_handlers: dict[Any, list[Any]] = {} + + def on_internal(self, event: Any, *filters: Any): + def decorator(handler: Any) -> Any: + self.internal_handlers.setdefault(event, []).append( + (handler, filters) + ) + return handler + + return decorator + + async def stop_startup_tasks(self) -> None: + return None + + +class FakeUploads: + def __init__(self) -> None: + self.photo_result: AttachPhotoPayload | None = AttachPhotoPayload( + photo_token="photo-token" + ) + self.video_result: VideoAttachPayload | None = VideoAttachPayload( + video_id=20, + token="video-token", + ) + self.file_result: AttachFilePayload | None = AttachFilePayload( + file_id=30 + ) + self.calls: list[tuple[str, Any]] = [] + + async def upload_photo(self, photo: Any) -> AttachPhotoPayload | None: + self.calls.append(("photo", photo)) + return self.photo_result + + async def upload_video(self, video: Any) -> VideoAttachPayload | None: + self.calls.append(("video", video)) + return self.video_result + + async def upload_file(self, file: Any) -> AttachFilePayload | None: + self.calls.append(("file", file)) + return self.file_result + + +def mobile_user_agent( + device_type: DeviceType = DeviceType.ANDROID, +) -> MobileUserAgentPayload: + kwargs: dict[str, Any] = { + "device_type": device_type, + "app_version": "26.14.1", + "os_version": ( + "Android 14" if device_type != DeviceType.WEB else "Linux" + ), + "timezone": "Europe/Moscow", + "screen": "1080x2400", + "locale": "ru", + "device_name": ( + "Pixel Test" if device_type != DeviceType.WEB else "Chrome" + ), + "device_locale": "ru", + } + if device_type != DeviceType.WEB: + kwargs.update( + { + "push_device_type": "GCM", + "arch": "arm64-v8a", + "build_number": 6686, + } + ) + return MobileUserAgentPayload(**kwargs) + + +def make_config(device_type: DeviceType = DeviceType.ANDROID) -> ClientConfig: + return ClientConfig( + phone="+79990000000", + work_dir=".", + session_name="session.db", + request_timeout=1.0, + telemetry=False, + sync=SyncOverrides(), + device=DeviceConfig( + mt_instance_id="mt-test", + device_id="device-test", + client_session_id=7, + user_agent=mobile_user_agent(device_type), + ), + ) + + +class FakeApp: + def __init__( + self, + responses: list[InboundFrame | Exception] | None = None, + *, + device_type: DeviceType = DeviceType.ANDROID, + ) -> None: + self.responses = list(responses or []) + self.calls: list[InvokeCall] = [] + self.config = make_config(device_type) + self.store = FakeStore() + self.dispatcher = FakeDispatcher() + self.connection = SimpleNamespace(is_open=True) + + self.me = None + self.chats = None + self.users: dict[int, Any] = {} + self.contacts: list[Any] = [] + self.messages: dict[int, list[Any]] = {} + self.session: SessionInfo | None = None + self.started = True + + self.api = SimpleNamespace() + self.api.uploads = FakeUploads() + self.api.messages = MessageService(self) + self.api.chats = ChatService(self) + self.api.users = UserService(self) + self.api.account = SelfService(self) + self.api.session = SessionService(self) + self.api.auth = AuthService(self) + self.api.bots = BotsService(self) + + async def invoke( + self, + opcode: int, + payload: dict[str, Any], + cmd: int = Command.REQUEST, + timeout: float | None = 30.0, + compress: bool = False, + ) -> InboundFrame: + self.calls.append(InvokeCall(opcode, payload, cmd, timeout, compress)) + if not self.responses: + return frame({}) + + response = self.responses.pop(0) + if isinstance(response, Exception): + raise response + return response + + +def frame( + payload: dict[Any, Any] | None, + *, + opcode: int = 0, + cmd: int = Command.RESPONSE, + seq: int | None = 1, +) -> InboundFrame: + return InboundFrame( + opcode=opcode, cmd=cmd, seq=seq, payload=payload, raw=payload + ) + + +def user_payload(user_id: int = 1, name: str = "Test User") -> dict[str, Any]: + return {"id": user_id, "names": [{"name": name, "type": "NICK"}]} + + +def profile_payload( + user_id: int = 1, options: list[int] | None = None +) -> dict[str, Any]: + return {"contact": user_payload(user_id), "profileOptions": options} + + +def chat_payload( + chat_id: int = 100, chat_type: str = "CHAT" +) -> dict[str, Any]: + return { + "id": chat_id, + "type": chat_type, + "status": "ACTIVE", + "owner": 1, + "title": f"Chat {chat_id}", + } + + +def message_payload( + message_id: int = 10, + chat_id: int | None = 100, + text: str = "hello", + *, + status: str | None = None, +) -> dict[str, Any]: + payload: dict[str, Any] = { + "id": message_id, + "chatId": chat_id, + "time": 123456, + "type": "USER", + "text": text, + } + if status is not None: + payload["status"] = status + return payload + + +def member_payload(user_id: int = 1, status: int = 1) -> dict[str, Any]: + return { + "contact": user_payload(user_id), + "presence": {"seen": 123456, "status": status}, + } + + +@pytest.fixture +def fake_app() -> FakeApp: + return FakeApp() diff --git a/tests/connection/test_connection.py b/tests/connection/test_connection.py new file mode 100644 index 0000000..13c1a6a --- /dev/null +++ b/tests/connection/test_connection.py @@ -0,0 +1,165 @@ +from __future__ import annotations + +import asyncio +from collections import deque + +import pytest + +from pymax.connection.connection import ConnectionManager +from pymax.connection.pending import PendingRequests +from pymax.protocol import Command, InboundFrame, OutboundFrame + + +class FakeProtocol: + version = 1 + + def encode(self, frame: OutboundFrame) -> bytes: + return f"{frame.seq}:{frame.opcode}".encode() + + def decode(self, raw: bytes | str) -> InboundFrame: + seq_raw, opcode_raw = bytes(raw).decode().split(":") + return InboundFrame( + opcode=int(opcode_raw), + cmd=Command.RESPONSE, + seq=int(seq_raw), + payload={"ok": True}, + raw={"ok": True}, + ) + + +class FakeTransport: + def __init__(self) -> None: + self.sent: list[bytes | str] = [] + self.connected = False + self.closed = False + + async def connect(self) -> None: + self.connected = True + + async def close(self) -> None: + self.closed = True + self.connected = False + + async def send(self, data: bytes | str) -> None: + self.sent.append(data) + + +class QueueReader: + def __init__(self, items: list[bytes | Exception]) -> None: + self.items = deque(items) + + async def read(self) -> bytes: + await asyncio.sleep(0) + item = self.items.popleft() + if isinstance(item, Exception): + raise item + return item + + +@pytest.mark.asyncio +async def test_pending_requests_resolve_reject_discard_and_cancel() -> None: + pending = PendingRequests() + + first = pending.create(1) + assert pending.resolve( + 1, InboundFrame(opcode=1, seq=1, payload={"ok": True}) + ) + assert (await first).payload == {"ok": True} + assert not pending.resolve(1, InboundFrame(opcode=1, seq=1)) + + second = pending.create(2) + assert pending.reject(2, RuntimeError("boom")) + with pytest.raises(RuntimeError, match="boom"): + await second + + third = pending.create(3) + pending.discard(3) + assert third.cancelled() + + fourth = pending.create(4) + pending.cancel_all(ConnectionError("lost")) + with pytest.raises(ConnectionError, match="lost"): + await fourth + + +@pytest.mark.asyncio +async def test_connection_next_seq_wraps_after_uint16_max() -> None: + manager = ConnectionManager( + reader=QueueReader([]), + transport=FakeTransport(), + protocol=FakeProtocol(), + ) + + manager._seq = 0xFFFE + + assert manager.next_seq() == 0xFFFF + assert manager.next_seq() == 0 + + +@pytest.mark.asyncio +async def test_connection_request_resolves_when_matching_response_arrives() -> ( + None +): + transport = FakeTransport() + manager = ConnectionManager( + reader=QueueReader([]), + transport=transport, + protocol=FakeProtocol(), + ) + frame = OutboundFrame( + ver=1, opcode=99, cmd=Command.REQUEST, seq=7, payload={} + ) + + task = asyncio.create_task(manager.request(frame, timeout=1)) + await asyncio.sleep(0) + await manager._handle_inbound( + InboundFrame( + opcode=99, cmd=Command.RESPONSE, seq=7, payload={"done": True} + ) + ) + + response = await task + + assert response.payload == {"done": True} + assert transport.sent == [b"7:99"] + + +@pytest.mark.asyncio +async def test_connection_open_recv_loop_dispatches_events_and_closes() -> ( + None +): + events: list[InboundFrame] = [] + transport = FakeTransport() + + async def on_event(event: InboundFrame) -> None: + events.append(event) + + manager = ConnectionManager( + reader=QueueReader([b"5:42", EOFError()]), + transport=transport, + protocol=FakeProtocol(), + on_event=on_event, + ) + + await manager.open() + await asyncio.sleep(0.01) + + with pytest.raises(ConnectionError, match="Connection lost"): + await manager.wait_closed() + + assert manager.is_open is False + await manager.close() + assert transport.closed is True + assert [event.opcode for event in events] == [42] + + +@pytest.mark.asyncio +async def test_send_requires_open_connection() -> None: + manager = ConnectionManager( + reader=QueueReader([]), + transport=FakeTransport(), + protocol=FakeProtocol(), + ) + + with pytest.raises(ConnectionError, match="Connection is not open"): + await manager.send(OutboundFrame(ver=1, opcode=1, seq=1, payload={})) diff --git a/tests/connection/test_readers_and_transports.py b/tests/connection/test_readers_and_transports.py new file mode 100644 index 0000000..d0044c0 --- /dev/null +++ b/tests/connection/test_readers_and_transports.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +import pytest + +from pymax.connection.readers.tcp import TCPReader +from pymax.connection.readers.ws import WSReader +from pymax.protocol import Command, Opcode +from pymax.protocol.tcp.framing import TcpPacketFramer +from pymax.transport.tcp import TCPTransport +from pymax.transport.websocket import WebSocketTransport + + +class ChunkTransport: + def __init__(self, chunks: list[bytes]) -> None: + self.chunks = list(chunks) + self.requests: list[int] = [] + + async def recv(self, n: int | None = None) -> bytes: + self.requests.append(n or 0) + return self.chunks.pop(0) + + +@pytest.mark.asyncio +async def test_tcp_reader_reads_header_then_payload() -> None: + framer = TcpPacketFramer() + packet = framer.pack( + ver=10, + cmd=Command.REQUEST, + seq=1, + opcode=Opcode.PING, + flags=0, + payload_bytes=b"abc", + ) + transport = ChunkTransport( + [packet[: framer.HEADER_SIZE], packet[framer.HEADER_SIZE :]] + ) + reader = TCPReader(transport, framer) + + assert await reader.read() == packet + assert transport.requests == [framer.HEADER_SIZE, 3] + + +@pytest.mark.asyncio +async def test_ws_reader_returns_transport_message() -> None: + class Transport: + async def recv(self): + return "message" + + assert await WSReader(Transport()).read() == "message" + + +class FakeStreamReader: + def __init__(self) -> None: + self.eof = False + + async def readexactly(self, n: int) -> bytes: + return b"x" * n + + def at_eof(self) -> bool: + return self.eof + + +class FakeStreamWriter: + def __init__(self) -> None: + self.writes: list[bytes] = [] + self.closed = False + + def write(self, data: bytes) -> None: + self.writes.append(data) + + async def drain(self) -> None: + return None + + def close(self) -> None: + self.closed = True + + async def wait_closed(self) -> None: + return None + + +@pytest.mark.asyncio +async def test_tcp_transport_connect_send_recv_and_close( + monkeypatch: pytest.MonkeyPatch, +) -> None: + reader = FakeStreamReader() + writer = FakeStreamWriter() + + async def open_connection(*args, **kwargs): + return reader, writer + + monkeypatch.setattr( + "pymax.transport.tcp.asyncio.open_connection", open_connection + ) + transport = TCPTransport("example.test", 443, proxy=None, use_ssl=True) + + await transport.connect() + await transport.send("hi") + data = await transport.recv(3) + await transport.close() + + assert transport.connected is False + assert writer.writes == [b"hi"] + assert data == b"xxx" + assert writer.closed is True + + +class FakeWebSocket: + def __init__(self) -> None: + self.close_code = None + self.sent: list[bytes | str] = [] + self.closed = False + + async def send(self, data: bytes | str) -> None: + self.sent.append(data) + + async def recv(self, decode=True): + return "incoming" + + async def close(self) -> None: + self.closed = True + self.close_code = 1000 + + async def wait_closed(self) -> None: + return None + + +@pytest.mark.asyncio +async def test_websocket_transport_connect_send_recv_and_close( + monkeypatch: pytest.MonkeyPatch, +) -> None: + ws = FakeWebSocket() + + async def connect(*args, **kwargs): + return ws + + monkeypatch.setattr("pymax.transport.websocket.client.connect", connect) + transport = WebSocketTransport("wss://example.test", proxy=None) + + await transport.connect() + await transport.send("hello") + incoming = await transport.recv() + await transport.close() + + assert ws.sent == ["hello"] + assert incoming == "incoming" + assert transport.connected is False diff --git a/tests/dispatch/test_dispatcher.py b/tests/dispatch/test_dispatcher.py new file mode 100644 index 0000000..a61abc0 --- /dev/null +++ b/tests/dispatch/test_dispatcher.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import pytest + +from pymax.dispatch import Dispatcher, Router +from pymax.dispatch.enums import EventType +from pymax.protocol import Command, Opcode +from tests.conftest import FakeApp, chat_payload, frame, message_payload + + +@pytest.mark.asyncio +async def test_dispatcher_routes_message_events_through_filters_and_raw_handler() -> ( + None +): + app = FakeApp() + router: Router[str] = Router() + dispatcher: Dispatcher[str] = Dispatcher(app, router) + dispatcher.bind_client("client") + seen: list[tuple[str, int | str]] = [] + + def is_start(message): + return message.text == "/start" + + async def has_chat(message): + return message.chat_id == 100 + + @router.on_message(is_start, has_chat) + async def on_message(message, client): + seen.append((client, message.id)) + + @router.on_raw() + def on_raw(raw_frame, client): + seen.append((client, f"raw:{raw_frame.opcode}")) + + await dispatcher.dispatch( + frame( + {"chatId": 100, "message": message_payload(1, 100, "/start")}, + opcode=Opcode.NOTIF_MESSAGE, + cmd=Command.REQUEST, + ) + ) + + assert seen == [ + ("client", 1), + ("client", f"raw:{int(Opcode.NOTIF_MESSAGE)}"), + ] + + +@pytest.mark.asyncio +async def test_dispatcher_maps_chat_delete_and_internal_attach_events() -> ( + None +): + app = FakeApp() + router: Router[str] = Router() + child: Router[str] = Router() + router.include_router(child) + dispatcher: Dispatcher[str] = Dispatcher(app, router) + dispatcher.bind_client("client") + seen: list[tuple[str, object]] = [] + + @child.on_chat_update() + async def on_chat(chat, _client): + seen.append(("chat", chat.id)) + + @router.on_message_delete() + async def on_delete(event, _client): + seen.append(("delete", tuple(event.message_ids))) + + @dispatcher.on_internal(EventType.FILE_READY) + async def on_file(signal, _client): + seen.append(("file", signal.file_id)) + + await dispatcher.dispatch( + frame( + {"chat": chat_payload(5)}, + opcode=Opcode.NOTIF_CHAT, + cmd=Command.REQUEST, + ) + ) + await dispatcher.dispatch( + frame( + {"chat": chat_payload(5), "messageIds": [1, 2]}, + opcode=Opcode.NOTIF_MSG_DELETE, + cmd=Command.REQUEST, + ) + ) + await dispatcher.dispatch( + frame({"fileId": 99}, opcode=Opcode.NOTIF_ATTACH, cmd=Command.REQUEST) + ) + + assert seen == [("chat", 5), ("delete", (1, 2)), ("file", 99)] + + +@pytest.mark.asyncio +async def test_dispatcher_requires_bound_client_for_callbacks() -> None: + dispatcher: Dispatcher[str] = Dispatcher(FakeApp()) + + async def callback(_event, _client): + return None + + with pytest.raises(RuntimeError, match="client is not bound"): + await dispatcher._call(callback, object()) diff --git a/tests/domain/test_bound_models.py b/tests/domain/test_bound_models.py new file mode 100644 index 0000000..d0f9c27 --- /dev/null +++ b/tests/domain/test_bound_models.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +import pytest + +from pymax.types.domain import Chat, Message, User +from tests.conftest import chat_payload, message_payload, user_payload + + +class MessageActions: + def __init__(self) -> None: + self.calls: list[tuple[str, tuple, dict]] = [] + + async def send_message(self, *args, **kwargs): + self.calls.append(("send_message", args, kwargs)) + return "sent" + + async def pin_message(self, *args, **kwargs): + self.calls.append(("pin_message", args, kwargs)) + return True + + async def delete_message(self, *args, **kwargs): + self.calls.append(("delete_message", args, kwargs)) + return True + + async def read_message(self, *args, **kwargs): + self.calls.append(("read_message", args, kwargs)) + return "read" + + async def add_reaction(self, *args, **kwargs): + self.calls.append(("add_reaction", args, kwargs)) + return "reacted" + + async def remove_reaction(self, *args, **kwargs): + self.calls.append(("remove_reaction", args, kwargs)) + return "removed" + + async def get_reactions(self, *args, **kwargs): + self.calls.append(("get_reactions", args, kwargs)) + return {"10": "reaction"} + + async def fetch_history(self, *args, **kwargs): + self.calls.append(("fetch_history", args, kwargs)) + return ["history"] + + +class ChatActions: + def __init__(self) -> None: + self.calls: list[tuple[str, tuple, dict]] = [] + + async def leave_group(self, *args, **kwargs): + self.calls.append(("leave_group", args, kwargs)) + + async def leave_channel(self, *args, **kwargs): + self.calls.append(("leave_channel", args, kwargs)) + + async def invite_users_to_group(self, *args, **kwargs): + self.calls.append(("invite_users_to_group", args, kwargs)) + return "group" + + async def invite_users_to_channel(self, *args, **kwargs): + self.calls.append(("invite_users_to_channel", args, kwargs)) + return "channel" + + async def remove_users_from_group(self, *args, **kwargs): + self.calls.append(("remove_users_from_group", args, kwargs)) + return True + + async def change_group_settings(self, *args, **kwargs): + self.calls.append(("change_group_settings", args, kwargs)) + + async def rework_invite_link(self, *args, **kwargs): + self.calls.append(("rework_invite_link", args, kwargs)) + return "new-link" + + +class UserActions: + async def add_contact(self, user_id): + return f"add:{user_id}" + + async def remove_contact(self, user_id): + return user_id == 1 + + def get_chat_id(self, first, second): + return first ^ second + + +@pytest.mark.asyncio +async def test_message_bound_methods_delegate_with_chat_and_message_ids() -> ( + None +): + actions = MessageActions() + message = Message.model_validate(message_payload(10, 100)).bind(actions) + + assert await message.reply("reply") == "sent" + assert await message.answer("answer", reply_to=9) == "sent" + assert await message.pin(notify_pin=False) is True + assert await message.delete(for_me=True) is True + assert await message.read() == "read" + assert await message.react("👍") == "reacted" + assert await message.unreact() == "removed" + assert await message.get_reactions() == {"10": "reaction"} + + assert actions.calls[0][2]["reply_to"] == 10 + assert actions.calls[1][2]["reply_to"] == 9 + assert actions.calls[3][2]["message_ids"] == [10] + assert actions.calls[5][2]["message_id"] == "10" + + +@pytest.mark.asyncio +async def test_unbound_message_raises_helpful_runtime_errors() -> None: + with pytest.raises(RuntimeError, match="not bound"): + await Message.model_validate(message_payload(10, 100)).answer("x") + + with pytest.raises(RuntimeError, match="chat_id"): + await ( + Message.model_validate(message_payload(10, None)) + .bind(MessageActions()) + .answer("x") + ) + + +@pytest.mark.asyncio +async def test_chat_bound_methods_delegate_by_chat_type() -> None: + messages = MessageActions() + chats = ChatActions() + group = Chat.model_validate(chat_payload(100, "CHAT")).bind( + messages, chats + ) + channel = Chat.model_validate(chat_payload(200, "CHANNEL")).bind( + messages, chats + ) + + assert await group.answer("hello") == "sent" + assert await group.history(backward=1) == ["history"] + await group.leave() + await channel.leave() + assert await group.invite([1, 2]) == "group" + assert await channel.invite([3]) == "channel" + assert await group.remove_users([2]) is True + assert await group.pin_message(10) is True + await group.update_settings(all_can_pin_message=True) + assert await group.rework_invite_link() == "new-link" + + assert messages.calls[0][2]["chat_id"] == 100 + assert chats.calls[0][0] == "leave_group" + assert chats.calls[1][0] == "leave_channel" + assert group.is_group is True + assert channel.is_channel is True + + +@pytest.mark.asyncio +async def test_dialog_leave_and_unbound_chat_raise_errors() -> None: + dialog = Chat.model_validate(chat_payload(1, "DIALOG")).bind( + MessageActions(), ChatActions() + ) + + with pytest.raises(RuntimeError, match="Cannot leave dialog"): + await dialog.leave() + + with pytest.raises(RuntimeError, match="not bound"): + await Chat.model_validate(chat_payload(2)).answer("x") + + +@pytest.mark.asyncio +async def test_user_bound_methods_delegate_to_user_service() -> None: + user = User.model_validate(user_payload(1)).bind(UserActions()) + + assert await user.add_contact() == "add:1" + assert await user.remove_contact() is True + assert user.get_chat_id(7) == 6 + + with pytest.raises(RuntimeError, match="not bound"): + await User.model_validate(user_payload(2)).add_contact() diff --git a/tests/files/test_files_and_formatting.py b/tests/files/test_files_and_formatting.py new file mode 100644 index 0000000..59230e6 --- /dev/null +++ b/tests/files/test_files_and_formatting.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import pytest + +from pymax.files import File, Photo +from pymax.formatting.markdown import Formatter + + +@pytest.mark.asyncio +async def test_file_raw_source_reads_sizes_and_chunks() -> None: + file = File(raw=b"abcdef", name="data.bin") + + assert await file.read() == b"abcdef" + assert await file.size() == 6 + assert [chunk async for chunk in file.iter_chunks(2)] == [ + b"ab", + b"cd", + b"ef", + ] + + with pytest.raises(ValueError, match="size must be greater"): + [chunk async for chunk in file.iter_chunks(0)] + + +@pytest.mark.asyncio +async def test_file_path_source_reads_sizes_and_chunks(tmp_path) -> None: + path = tmp_path / "report.txt" + path.write_bytes(b"report") + file = File(path=str(path)) + + assert file.name == "report.txt" + assert await file.read() == b"report" + assert await file.size() == 6 + assert [chunk async for chunk in file.iter_chunks(4)] == [b"repo", b"rt"] + + +def test_file_and_photo_validation_errors() -> None: + with pytest.raises(ValueError, match="Path or Url or Raw"): + File(name="empty.txt") + + with pytest.raises(ValueError, match="Only one"): + File(raw=b"x", path="x.txt", name="x.txt") + + with pytest.raises(ValueError, match="Invalid photo extension"): + Photo(raw=b"not image", name="bad.txt").validate_photo() + + +def test_markdown_formatter_extracts_functional_entities() -> None: + clean, entities = Formatter.format_markdown( + "# Title\n> Quote\nHello **bold** and [site](https://example.com)" + ) + + assert clean == "Title\nQuote\nHello bold and site" + assert [ + (entity.type, entity.from_, entity.length) for entity in entities + ] == [ + ("HEADING", 0, 5), + ("QUOTE", 6, 5), + ("STRONG", 18, 4), + ("LINK", 27, 4), + ] + assert entities[-1].attributes is not None + assert entities[-1].attributes.url == "https://example.com" diff --git a/tests/protocol/test_protocols.py b/tests/protocol/test_protocols.py new file mode 100644 index 0000000..b35b984 --- /dev/null +++ b/tests/protocol/test_protocols.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import msgpack +import pytest + +from pymax.api.messages.enums import ItemType +from pymax.protocol import Command, InboundFrame, Opcode, OutboundFrame +from pymax.protocol.tcp.compression import Lz4BlockCompression +from pymax.protocol.tcp.framing import TcpPacketFramer +from pymax.protocol.tcp.payload import MsgpackPayloadCodec, TcpPayloadDecoder +from pymax.protocol.tcp.protocol import TcpProtocol +from pymax.protocol.ws.protocol import WsProtocol + + +def test_websocket_protocol_roundtrip_and_invalid_frames() -> None: + protocol = WsProtocol() + outbound = OutboundFrame( + ver=protocol.version, + opcode=Opcode.PING, + cmd=Command.REQUEST, + seq=7, + payload={"interactive": True}, + ) + + encoded = protocol.encode(outbound) + decoded = protocol.decode(encoded) + + assert decoded.opcode == Opcode.PING + assert decoded.payload == {"interactive": True} + assert protocol.decode("{").opcode == 0 + assert protocol.decode('{"opcode": "bad"}').payload is None + + +def test_tcp_protocol_roundtrip() -> None: + protocol = TcpProtocol() + outbound = OutboundFrame( + ver=protocol.version, + opcode=Opcode.CHAT_HISTORY, + cmd=Command.REQUEST, + seq=3, + payload={"chatId": 100, "itemType": ItemType.REGULAR}, + ) + + decoded = protocol.decode(protocol.encode(outbound)) + + assert decoded == InboundFrame( + opcode=Opcode.CHAT_HISTORY, + cmd=Command.REQUEST, + seq=3, + payload={"chatId": 100, "itemType": ItemType.REGULAR.value}, + raw={"chatId": 100, "itemType": ItemType.REGULAR.value}, + ) + + +def test_tcp_protocol_supports_two_byte_sequence_ids() -> None: + protocol = TcpProtocol() + outbound = OutboundFrame( + ver=protocol.version, + opcode=Opcode.PING, + cmd=Command.REQUEST, + seq=0xFFFF, + payload={"interactive": True}, + ) + + decoded = protocol.decode(protocol.encode(outbound)) + + assert decoded.seq == 0xFFFF + assert decoded.opcode == Opcode.PING + + +def test_tcp_framer_uses_expected_header_layout() -> None: + framer = TcpPacketFramer() + packed = framer.pack( + ver=10, + cmd=Command.RESPONSE, + seq=0x0100, + opcode=Opcode.PING, + flags=2, + payload_bytes=b"abc", + ) + + assert framer.HEADER_SIZE == 10 + assert packed[: framer.HEADER_SIZE] == bytes( + [0x0A, 0x01, 0x01, 0x00, 0x00, 0x01, 0x02, 0x00, 0x00, 0x03] + ) + + +def test_tcp_framer_handles_short_and_incomplete_packets() -> None: + framer = TcpPacketFramer() + packed = framer.pack( + ver=10, + cmd=Command.RESPONSE, + seq=1, + opcode=Opcode.PING, + flags=2, + payload_bytes=b"abc", + ) + + packet = framer.unpack(packed) + + assert framer.unpack(b"short") is None + assert framer.unpack(packed[:-1]) is None + assert framer.unpack_header(b"short") is None + assert framer.unpack_header(packed[: framer.HEADER_SIZE]) == 3 + assert packet is not None + assert packet.header.cmd == Command.RESPONSE + assert packet.header.flags == 2 + assert packet.payload_bytes == b"abc" + + +def test_msgpack_codec_serializes_enums_and_decoder_normalizes_keys() -> None: + codec = MsgpackPayloadCodec() + encoded = codec.encode( + {1: {b"name": ItemType.DELAYED}, "list": [ItemType.REGULAR]} + ) + decoded = TcpPayloadDecoder(serializer=codec).decode(encoded) + + assert decoded == { + "1": {"name": ItemType.DELAYED.value}, + "list": [ItemType.REGULAR.value], + } + + +def test_msgpack_codec_uses_first_dict_when_stream_has_extra_data() -> None: + codec = MsgpackPayloadCodec() + encoded = msgpack.packb({"ok": True}, use_bin_type=True) + msgpack.packb( + ["ignored"], use_bin_type=True + ) + + assert codec.decode(encoded) == {"ok": True} + + +def test_lz4_decompresses_literals_and_rejects_invalid_blocks() -> None: + compression = Lz4BlockCompression() + + assert compression.decompress(bytes([0x50]) + b"hello") == b"hello" + + with pytest.raises(ValueError, match="zero offset"): + compression.decompress(bytes([0x01, 0x00, 0x00])) diff --git a/tests/session/test_store.py b/tests/session/test_store.py new file mode 100644 index 0000000..c0fc1d3 --- /dev/null +++ b/tests/session/test_store.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import pytest + +from pymax.session.models import SessionInfo +from pymax.session.store import SessionStore +from pymax.types.domain.sync import SyncState + + +@pytest.mark.asyncio +async def test_session_store_saves_loads_updates_and_deletes_session( + tmp_path, +) -> None: + store = SessionStore(str(tmp_path), "test-session.db") + session = SessionInfo( + token="token-1", + device_id="device-1", + phone="+79990000000", + mt_instance_id="mt-1", + sync=SyncState( + chats_sync=1, + contacts_sync=2, + drafts_sync=3, + presence_sync=4, + config_hash="hash-1", + ), + ) + + await store.save_session(session) + + loaded = await store.load_session() + by_device = await store.load_session_by_device_id("device-1") + by_phone = await store.load_session_by_phone("+79990000000") + + assert loaded == session + assert by_device == session + assert by_phone == session + + await store.update_token("token-1", "token-2") + loaded_after_update = await store.load_session() + assert loaded_after_update is not None + assert loaded_after_update.token == "token-2" + + await store.delete_session("token-2") + assert await store.load_session() is None + + await store.close() + assert store.conn is None diff --git a/tests/telemetry/test_telemetry.py b/tests/telemetry/test_telemetry.py new file mode 100644 index 0000000..fe861db --- /dev/null +++ b/tests/telemetry/test_telemetry.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +import asyncio +from random import Random + +import pytest + +from pymax.telemetry.navigation import ( + NavigationPlanner, + RouteProfile, + Screen, + ScreenTransition, +) +from pymax.telemetry.payloads import TelemetryPayloadBuilder +from pymax.telemetry.service import TelemetryService +from pymax.types.domain import Chat, Profile +from tests.conftest import FakeApp, chat_payload, profile_payload + + +class ControlledRng: + def __init__(self) -> None: + self.random_values: list[float] = [] + self.uniform_values: list[float] = [] + self.randint_values: list[int] = [] + + def random(self) -> float: + return self.random_values.pop(0) + + def uniform(self, left: float, right: float) -> float: + if self.uniform_values: + return self.uniform_values.pop(0) + return left + + def randint(self, left: int, right: int) -> int: + if self.randint_values: + return self.randint_values.pop(0) + return left + + def choice(self, items): + return tuple(items)[0] + + +def test_route_profile_pause_and_navigation_planner_history() -> None: + rng = ControlledRng() + profile = RouteProfile( + steps=1, + min_pause=1, + max_pause=2, + long_pause_chance=0.5, + min_long_pause=10, + max_long_pause=20, + back_chance=0.5, + ) + rng.random_values = [0.4, 0.6] + rng.uniform_values = [15, 1.5] + + assert profile.pause(rng) == 15 + assert profile.pause(rng) == 1.5 + + planner = NavigationPlanner(Random(1)) + assert planner.current_screen == Screen.BACKGROUND + next_screen = planner.next_screen(profile) + assert next_screen in {Screen.CHATS, Screen.SETTINGS} + assert planner.history + planner.reset_to_background() + assert planner.current_screen == Screen.BACKGROUND + assert planner.history == [] + + +def test_navigation_weighted_choice_falls_back_to_last_transition() -> None: + rng = ControlledRng() + rng.randint_values = [100] + planner = NavigationPlanner(rng) + + assert ( + planner._weighted_choice( + ( + ScreenTransition(screen=Screen.CHATS, weight=1), + ScreenTransition(screen=Screen.SETTINGS, weight=1), + ) + ) + == Screen.SETTINGS + ) + + +def test_telemetry_payload_builder_creates_real_event_payloads() -> None: + builder = TelemetryPayloadBuilder(Random(1)) + + login = builder.login(user_id=10, session_id=20) + nav = builder.navigation( + user_id=10, + session_id=20, + screen_from=int(Screen.BACKGROUND), + screen_to=int(Screen.CHATS), + prev_time=1, + action_id=2, + extra_params={"source_id": 3}, + ) + open_chat = builder.open_chat(10, 20) + open_chats = builder.open_chats(10, 20) + payload = builder.to_payload([login, nav, open_chat, open_chats]) + + assert login.event == "login" + assert nav.params["screen_to"] == int(Screen.CHATS) + assert open_chat.event == "open_chat_to_render" + assert open_chats.event == "open_chats_to_render" + assert len(payload["events"]) == 4 + + +@pytest.mark.asyncio +async def test_telemetry_service_start_stop_send_and_source_params( + monkeypatch: pytest.MonkeyPatch, +) -> None: + app = FakeApp() + app.me = Profile.model_validate(profile_payload(9)) + app.chats = [Chat.model_validate(chat_payload(100, "DIALOG"))] + service = TelemetryService(app) + + async def fake_run(): + await asyncio.Event().wait() + + monkeypatch.setattr(service, "_run", fake_run) + + assert service._ready is True + assert service._source_params(Screen.CHATS)["tab_config"] == 2 + assert service._source_params(Screen.CHAT) == { + "source_type": 1, + "source_id": 100, + } + + await service._send_events([service._payloads.login(9, 7)]) + assert app.calls[-1].payload["events"][0]["event"] == "login" + + service.start() + assert service._task is not None + await service.stop() + assert service._task is None + + +def test_telemetry_service_ready_and_user_id_guards() -> None: + app = FakeApp() + service = TelemetryService(app) + + assert service._ready is False + with pytest.raises(RuntimeError, match="authenticated profile"): + _ = service._user_id diff --git a/uv.lock b/uv.lock index c0376a4..9225083 100644 --- a/uv.lock +++ b/uv.lock @@ -255,50 +255,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, ] -[[package]] -name = "black" -version = "26.5.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "mypy-extensions" }, - { name = "packaging" }, - { name = "pathspec" }, - { name = "platformdirs" }, - { name = "pytokens" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c0/37/5628dd55bf2b34257fc7603f0fe97c40e3aaf24265f416a9c85c95ca1436/black-26.5.1.tar.gz", hash = "sha256:dd321f668053961824bcc1be1cc1df748b2d7e4fa28086b08331e577b0100a73", size = 679439, upload-time = "2026-05-18T16:53:36.107Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/be/84/b3f55026206a9e8820a91503308075ca48eadc515e436731ca01dbe043b3/black-26.5.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9942db8888e06943c5dde66ca0037dcff82a2a4ec1ad0ada9e0d2ee9d9823893", size = 1987719, upload-time = "2026-05-18T17:05:02.757Z" }, - { url = "https://files.pythonhosted.org/packages/c6/34/7db312c5e5783d6e76cffd9d5ac8972a32badae4c6e3288dac0eed8d3bed/black-26.5.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:89c93167a74d3a75dfaa38a5c7cca015537d5820dd7f17d63267d674a61cae90", size = 1810083, upload-time = "2026-05-18T17:05:04.302Z" }, - { url = "https://files.pythonhosted.org/packages/33/e2/e0101e73c2c8727634e2efcb35e2b34bd23ad70dfa673789f5773a591b21/black-26.5.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22f2cd76d069cc54c71f10360744ba8983fbb616903b4304a85b734915c8e1b4", size = 1860633, upload-time = "2026-05-18T17:05:06.391Z" }, - { url = "https://files.pythonhosted.org/packages/b0/4c/e15c0c5b23cf3651035fe5addcce90e283af3548a3f91bb03d81b83106ab/black-26.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:87ed5c6f450580a2f6790bc7cbfb016dfc73bc750249762268a3695361315eef", size = 1477886, upload-time = "2026-05-18T17:05:07.96Z" }, - { url = "https://files.pythonhosted.org/packages/9f/3f/59d43ade98d2ce5c8dc34a4e46cbecd177e6d55d7d4092969c6003ccc655/black-26.5.1-cp310-cp310-win_arm64.whl", hash = "sha256:58b4bd92cf88aacf83d88479c8f9caee044b1ec55f2451a337354a7ea2590a22", size = 1277111, upload-time = "2026-05-18T17:05:09.473Z" }, - { url = "https://files.pythonhosted.org/packages/4b/96/3c3e09f09f44a37aac36b178a279cd19aa7001bd796187a7b162a294c81f/black-26.5.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:96ae2c733b2aabdd9986e2c5df628ff3473676cd1c5faded1ff496cf6d74083c", size = 1970639, upload-time = "2026-05-18T17:05:11.461Z" }, - { url = "https://files.pythonhosted.org/packages/83/ea/5ad117b9ee3ecd933c712bcbae610006e5b7cc9f41c526cd7ed3b6c4124c/black-26.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0e48b87e03bf109288e55cfceadcfa15ff5470aca2851a851950ed2926f450d7", size = 1792130, upload-time = "2026-05-18T17:05:12.983Z" }, - { url = "https://files.pythonhosted.org/packages/06/3a/7c448bc623fcdfa96672531beb5a616ea5e64f6975955254d7731ffb0ad9/black-26.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5119fa92ae61f786e8c3662fd60aece1d0a2dd5cca5d0c79417a95e7a4272a59", size = 1846134, upload-time = "2026-05-18T17:05:14.506Z" }, - { url = "https://files.pythonhosted.org/packages/a1/5b/0b39b3a5917f0657ac014ad2edb58c139553a478adfe7f817abf1622ff6e/black-26.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:30d3c14661f2792e9142cce3eeeb1cbc175b3eb5f733be0c8eeb99651e52b0c3", size = 1478883, upload-time = "2026-05-18T17:05:16.542Z" }, - { url = "https://files.pythonhosted.org/packages/4c/48/dc222692e0f95030db1bbfb6c857e76858bad09058221ea7aae815255327/black-26.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:1ef92b76f7733f282fd096ea406200b5a286c42947412b0eaff3a74e3616cefe", size = 1277776, upload-time = "2026-05-18T17:05:18.029Z" }, - { url = "https://files.pythonhosted.org/packages/24/99/7744b906703228264ef73bdd534df88ec1ef3de45c4e78f6d31b9e32d0c9/black-26.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4ad6fa01f941920f54f2bbb35f3df7673428a0ef98a0b0840c2eaef3b110efa8", size = 2012518, upload-time = "2026-05-18T17:05:20.108Z" }, - { url = "https://files.pythonhosted.org/packages/b7/c0/c5a3b1636dfd09c42534f2b3cf33506814f6d3e066fb0879ffa16c1ae860/black-26.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3915f256e75a2d7cf88d8953d37f780455dc586cc72dee059c528fe77f581217", size = 1816016, upload-time = "2026-05-18T17:05:21.84Z" }, - { url = "https://files.pythonhosted.org/packages/1f/0e/36044316b65ca471d3bb6d3703fd06fb50c6b727c3562f6a5a3153634f88/black-26.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d98d4137277c75dfb898ec8d846c4fd68ba1e9cf77f95e2865c203dc18f4c3d", size = 1884150, upload-time = "2026-05-18T17:05:23.546Z" }, - { url = "https://files.pythonhosted.org/packages/b3/33/dafc5808c2af43672912111d7c3354af1615f7e2be3bed7a878461abbe4d/black-26.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:a1dca32d9f1784af512a13410ec204c6f7f0aa9797a111c42e1c03449821c264", size = 1486825, upload-time = "2026-05-18T17:05:25.004Z" }, - { url = "https://files.pythonhosted.org/packages/82/14/b965ee6ad2a311f28bdbf692def3ee9848d2ae289dab28b27657fcee3e78/black-26.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1037d5ac7b7b310b2632ad867ec8d0e4c4819dcdb0b820f63135da746a24e418", size = 1288646, upload-time = "2026-05-18T17:05:26.477Z" }, - { url = "https://files.pythonhosted.org/packages/3f/5c/c384363980e11e25ca6b93205949bb331fbf35f4e0dbec376dfa6326cec8/black-26.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2b36cf2ddf5566e205f6535f782a62194a184d33e175b64ae8c40b1737522be3", size = 2009020, upload-time = "2026-05-18T17:05:28.132Z" }, - { url = "https://files.pythonhosted.org/packages/0b/df/9f31c5e0babbfed77d505fc5d120beb98b21b33feaeded3924ea941fe360/black-26.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f7ea64ebfa01b50f693508fc39f875e264446d3b097088f84f203b9d09618a0", size = 1813335, upload-time = "2026-05-18T17:05:31.266Z" }, - { url = "https://files.pythonhosted.org/packages/fb/24/8e7b9a2fa61b0afd82209efe937557d180a1fa055bd7f6161eb9defc3719/black-26.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecb3e624844c798144e9bd986954e0adc81d8911a1f30f375e1252fe26e8c294", size = 1881614, upload-time = "2026-05-18T17:05:32.718Z" }, - { url = "https://files.pythonhosted.org/packages/49/ad/b4e0d9365ba8ac34f6bbab62a4b1b2dd5d618fac3fa1b8db968c844201b5/black-26.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:e1a26503279b6b310669fb0b219c39e4820b77e8189fe80f522bb511f247db0a", size = 1488925, upload-time = "2026-05-18T17:05:34.259Z" }, - { url = "https://files.pythonhosted.org/packages/a1/4b/652b859bf5df88a751c30451b09338f7fd26a77d1271c666992f836b7711/black-26.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c34b25da232ead53a6f335b76dbea124f4d152ad568b9080d6f944bc2b34b52", size = 1289883, upload-time = "2026-05-18T17:05:36.019Z" }, - { url = "https://files.pythonhosted.org/packages/a6/16/a8da8eb208c51c7f4ce74609a45d0dcc6d8a2141e45e81ee5289d1bb0d59/black-26.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e88976690a64b0af98312ca958415849cb42423423c5f2ee74af4b49a97a2168", size = 2004800, upload-time = "2026-05-18T17:05:38.182Z" }, - { url = "https://files.pythonhosted.org/packages/11/8a/a479296a19e383b70a725882a6cf3d786540601ff03cabbaaf1cce864c5a/black-26.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32d5ea7f6c8bdfa6e648326ebca1f02b0764e2a029edc6f8dce2627e19d468c3", size = 1815576, upload-time = "2026-05-18T17:05:40.309Z" }, - { url = "https://files.pythonhosted.org/packages/81/6b/cfaf3d39f25132c156a068f6b805576c9103a84086019507c70e1911ee7d/black-26.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea8d16dc41655aa113cd64665e7219446cd7e4ff2248d7178eaa905190c86b18", size = 1877927, upload-time = "2026-05-18T17:05:42.463Z" }, - { url = "https://files.pythonhosted.org/packages/66/76/302e313964bcff7e28df329d39f84f5270095730d85ff0acc260610a0d82/black-26.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:577f21094ea469ef92ec1adaf2c9441a226d2144d01a5be2fa823cecf6543e50", size = 1511860, upload-time = "2026-05-18T17:05:43.943Z" }, - { url = "https://files.pythonhosted.org/packages/27/4e/a3827e35e0e567f9f9ee59e2a0ab979267dca98718f25547ca8c6733afd4/black-26.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:ed1a20af114c301a0269bf01163d51dbef72737fd65f850001e7cbe7f3c7abae", size = 1316632, upload-time = "2026-05-18T17:05:45.521Z" }, - { url = "https://files.pythonhosted.org/packages/94/51/f975cae76d44274cc2868dc9040ac5d58d464784610234455b4e7b19c6ef/black-26.5.1-py3-none-any.whl", hash = "sha256:4ed7f7da04046d2e488437170797d3b4a4ad83906683bcb7dfc68b673bbce5e2", size = 213693, upload-time = "2026-05-18T16:53:33.964Z" }, -] - [[package]] name = "build" version = "1.5.0" @@ -365,6 +321,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, ] +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.7" @@ -470,18 +435,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, ] -[[package]] -name = "click" -version = "8.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/23/e4/796662cd90cf80e3a363c99db2b88e0e394b988a575f60a17e16440cd011/click-8.4.0.tar.gz", hash = "sha256:638f1338fe1235c8f4e008e4a8a254fb5c5fbdcbb40ece3c9142ebb78e792973", size = 350843, upload-time = "2026-05-17T00:47:58.425Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ee/ae/8e92f8058baf87f6c7d86ee7e457668690195cc77efedb8d3797a06e3940/click-8.4.0-py3-none-any.whl", hash = "sha256:40c50b7c6c6adac2823d411041ec84f3f103f1b280d5e9ce0d7f998995832f81", size = 116147, upload-time = "2026-05-17T00:47:56.842Z" }, -] - [[package]] name = "colorama" version = "0.4.6" @@ -652,6 +605,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b4/e1/48cedb2fe63626e91ded1edad159e2a4fb8b6906c4425eb7749673077ce7/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355", size = 4666800, upload-time = "2026-05-04T22:59:27.474Z" }, ] +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + [[package]] name = "docutils" version = "0.21.2" @@ -690,17 +652,12 @@ wheels = [ ] [[package]] -name = "flake8" -version = "7.3.0" +name = "filelock" +version = "3.29.0" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mccabe" }, - { name = "pycodestyle" }, - { name = "pyflakes" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9b/af/fbfe3c4b5a657d79e5c47a2827a362f9e1b763336a52f926126aa6dc7123/flake8-7.3.0.tar.gz", hash = "sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872", size = 48326, upload-time = "2025-06-20T19:31:35.838Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/56/13ab06b4f93ca7cac71078fbe37fcea175d3216f31f85c3168a6bbd0bb9a/flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e", size = 57922, upload-time = "2025-06-20T19:31:34.425Z" }, + { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" }, ] [[package]] @@ -854,6 +811,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/42/77/de194443bf38daed9452139e960c632b0ef9f9a5dd9ce605fdf18ca9f1b1/id-1.6.1-py3-none-any.whl", hash = "sha256:f5ec41ed2629a508f5d0988eda142e190c9c6da971100612c4de9ad9f9b237ca", size = 14689, upload-time = "2026-02-04T16:19:40.051Z" }, ] +[[package]] +name = "identify" +version = "2.6.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, +] + [[package]] name = "idna" version = "3.13" @@ -893,15 +859,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] -[[package]] -name = "isort" -version = "8.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ef/7c/ec4ab396d31b3b395e2e999c8f46dec78c5e29209fac49d1f4dace04041d/isort-8.0.1.tar.gz", hash = "sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d", size = 769592, upload-time = "2026-02-28T10:08:20.685Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3e/95/c7c34aa53c16353c56d0b802fba48d5f5caa2cdee7958acbcb795c830416/isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75", size = 89733, upload-time = "2026-02-28T10:08:19.466Z" }, -] - [[package]] name = "jaraco-classes" version = "3.4.0" @@ -1076,7 +1033,7 @@ wheels = [ [[package]] name = "maxapi-python" -version = "2.0.0" +version = "2.1.0" source = { editable = "." } dependencies = [ { name = "aiofiles" }, @@ -1106,16 +1063,15 @@ test = [ [package.dev-dependencies] dev = [ - { name = "black" }, { name = "build" }, - { name = "flake8" }, { name = "furo" }, - { name = "isort" }, + { name = "pre-commit" }, { name = "pyright" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "pytest-timeout" }, + { name = "ruff" }, { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, @@ -1158,16 +1114,15 @@ provides-extras = ["docs", "test"] [package.metadata.requires-dev] dev = [ - { name = "black", specifier = ">=24.0.0" }, { name = "build", specifier = ">=1.2.0" }, - { name = "flake8", specifier = ">=7.0.0" }, { name = "furo", specifier = ">=2025.12.19" }, - { name = "isort", specifier = ">=5.13.0" }, + { name = "pre-commit", specifier = ">=4.0.0" }, { name = "pyright", specifier = ">=1.1.390" }, { name = "pytest", specifier = ">=8.0.0" }, { name = "pytest-asyncio", specifier = ">=0.24.0" }, { name = "pytest-cov", specifier = ">=5.0.0" }, { name = "pytest-timeout", specifier = ">=2.1.0" }, + { name = "ruff", specifier = ">=0.8.0" }, { name = "sphinx", specifier = ">=8.1.3" }, { name = "sphinx-copybutton", specifier = ">=0.5.2" }, { name = "twine", specifier = ">=5.0.0" }, @@ -1184,15 +1139,6 @@ test = [ { name = "pytest-timeout", specifier = ">=2.1.0" }, ] -[[package]] -name = "mccabe" -version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" }, -] - [[package]] name = "mdurl" version = "0.1.2" @@ -1410,15 +1356,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, ] -[[package]] -name = "mypy-extensions" -version = "1.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, -] - [[package]] name = "nh3" version = "0.3.5" @@ -1471,15 +1408,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] -[[package]] -name = "pathspec" -version = "1.1.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, -] - [[package]] name = "platformdirs" version = "4.9.6" @@ -1498,6 +1426,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pre-commit" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, +] + [[package]] name = "propcache" version = "0.4.1" @@ -1612,15 +1556,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, ] -[[package]] -name = "pycodestyle" -version = "2.14.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/11/e0/abfd2a0d2efe47670df87f3e3a0e2edda42f055053c85361f19c0e2c1ca8/pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783", size = 39472, upload-time = "2025-06-20T18:49:48.75Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d", size = 31594, upload-time = "2025-06-20T18:49:47.491Z" }, -] - [[package]] name = "pycparser" version = "3.0" @@ -1761,15 +1696,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/75/f3/0a7087e5f861d66ca64ce927230b397cc264c87b712156e6a93b26a459c8/pydantic_core-2.46.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:154dbfdfb11b8cbd8ff4d00d0b81e3d19f4cb4bedd5aa9f091060ba071474c6a", size = 2192159, upload-time = "2026-04-17T09:11:20.123Z" }, ] -[[package]] -name = "pyflakes" -version = "3.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/45/dc/fd034dc20b4b264b3d015808458391acbf9df40b1e54750ef175d39180b1/pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58", size = 64669, upload-time = "2025-06-20T18:45:27.834Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/2f/81d580a0fb83baeb066698975cb14a618bdbed7720678566f1b046a95fe8/pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f", size = 63551, upload-time = "2025-06-20T18:45:26.937Z" }, -] - [[package]] name = "pygments" version = "2.20.0" @@ -1859,6 +1785,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" }, ] +[[package]] +name = "python-discovery" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/60/e88788207d81e46362cfbef0d4aaf4c0f49efc3c12d4c3fa3f542c34ebec/python_discovery-1.3.1.tar.gz", hash = "sha256:62f6db28064c9613e7ca76cb3f00c38c839a07c31c00dfe7ed0986493d2150a6", size = 68011, upload-time = "2026-05-12T20:53:36.336Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/6f/a05a317a66fee0aad270011461f1a63a453ed12471249f172f7d2e2bc7b4/python_discovery-1.3.1-py3-none-any.whl", hash = "sha256:ed188687ebb3b82c01a17cd5ac62fc94d9f6487a7f1a0f9dfe89753fec91039c", size = 33185, upload-time = "2026-05-12T20:53:34.969Z" }, +] + [[package]] name = "python-socks" version = "2.8.1" @@ -1873,45 +1812,6 @@ asyncio = [ { name = "async-timeout", marker = "python_full_version < '3.11'" }, ] -[[package]] -name = "pytokens" -version = "0.4.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/42/24/f206113e05cb8ef51b3850e7ef88f20da6f4bf932190ceb48bd3da103e10/pytokens-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a44ed93ea23415c54f3face3b65ef2b844d96aeb3455b8a69b3df6beab6acc5", size = 161522, upload-time = "2026-01-30T01:02:50.393Z" }, - { url = "https://files.pythonhosted.org/packages/d4/e9/06a6bf1b90c2ed81a9c7d2544232fe5d2891d1cd480e8a1809ca354a8eb2/pytokens-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:add8bf86b71a5d9fb5b89f023a80b791e04fba57960aa790cc6125f7f1d39dfe", size = 246945, upload-time = "2026-01-30T01:02:52.399Z" }, - { url = "https://files.pythonhosted.org/packages/69/66/f6fb1007a4c3d8b682d5d65b7c1fb33257587a5f782647091e3408abe0b8/pytokens-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:670d286910b531c7b7e3c0b453fd8156f250adb140146d234a82219459b9640c", size = 259525, upload-time = "2026-01-30T01:02:53.737Z" }, - { url = "https://files.pythonhosted.org/packages/04/92/086f89b4d622a18418bac74ab5db7f68cf0c21cf7cc92de6c7b919d76c88/pytokens-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4e691d7f5186bd2842c14813f79f8884bb03f5995f0575272009982c5ac6c0f7", size = 262693, upload-time = "2026-01-30T01:02:54.871Z" }, - { url = "https://files.pythonhosted.org/packages/b4/7b/8b31c347cf94a3f900bdde750b2e9131575a61fdb620d3d3c75832262137/pytokens-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:27b83ad28825978742beef057bfe406ad6ed524b2d28c252c5de7b4a6dd48fa2", size = 103567, upload-time = "2026-01-30T01:02:56.414Z" }, - { url = "https://files.pythonhosted.org/packages/3d/92/790ebe03f07b57e53b10884c329b9a1a308648fc083a6d4a39a10a28c8fc/pytokens-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d70e77c55ae8380c91c0c18dea05951482e263982911fc7410b1ffd1dadd3440", size = 160864, upload-time = "2026-01-30T01:02:57.882Z" }, - { url = "https://files.pythonhosted.org/packages/13/25/a4f555281d975bfdd1eba731450e2fe3a95870274da73fb12c40aeae7625/pytokens-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a58d057208cb9075c144950d789511220b07636dd2e4708d5645d24de666bdc", size = 248565, upload-time = "2026-01-30T01:02:59.912Z" }, - { url = "https://files.pythonhosted.org/packages/17/50/bc0394b4ad5b1601be22fa43652173d47e4c9efbf0044c62e9a59b747c56/pytokens-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b49750419d300e2b5a3813cf229d4e5a4c728dae470bcc89867a9ad6f25a722d", size = 260824, upload-time = "2026-01-30T01:03:01.471Z" }, - { url = "https://files.pythonhosted.org/packages/4e/54/3e04f9d92a4be4fc6c80016bc396b923d2a6933ae94b5f557c939c460ee0/pytokens-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9907d61f15bf7261d7e775bd5d7ee4d2930e04424bab1972591918497623a16", size = 264075, upload-time = "2026-01-30T01:03:04.143Z" }, - { url = "https://files.pythonhosted.org/packages/d1/1b/44b0326cb5470a4375f37988aea5d61b5cc52407143303015ebee94abfd6/pytokens-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:ee44d0f85b803321710f9239f335aafe16553b39106384cef8e6de40cb4ef2f6", size = 103323, upload-time = "2026-01-30T01:03:05.412Z" }, - { url = "https://files.pythonhosted.org/packages/41/5d/e44573011401fb82e9d51e97f1290ceb377800fb4eed650b96f4753b499c/pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083", size = 160663, upload-time = "2026-01-30T01:03:06.473Z" }, - { url = "https://files.pythonhosted.org/packages/f0/e6/5bbc3019f8e6f21d09c41f8b8654536117e5e211a85d89212d59cbdab381/pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1", size = 255626, upload-time = "2026-01-30T01:03:08.177Z" }, - { url = "https://files.pythonhosted.org/packages/bf/3c/2d5297d82286f6f3d92770289fd439956b201c0a4fc7e72efb9b2293758e/pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1", size = 269779, upload-time = "2026-01-30T01:03:09.756Z" }, - { url = "https://files.pythonhosted.org/packages/20/01/7436e9ad693cebda0551203e0bf28f7669976c60ad07d6402098208476de/pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9", size = 268076, upload-time = "2026-01-30T01:03:10.957Z" }, - { url = "https://files.pythonhosted.org/packages/2e/df/533c82a3c752ba13ae7ef238b7f8cdd272cf1475f03c63ac6cf3fcfb00b6/pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68", size = 103552, upload-time = "2026-01-30T01:03:12.066Z" }, - { url = "https://files.pythonhosted.org/packages/cb/dc/08b1a080372afda3cceb4f3c0a7ba2bde9d6a5241f1edb02a22a019ee147/pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b", size = 160720, upload-time = "2026-01-30T01:03:13.843Z" }, - { url = "https://files.pythonhosted.org/packages/64/0c/41ea22205da480837a700e395507e6a24425151dfb7ead73343d6e2d7ffe/pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f", size = 254204, upload-time = "2026-01-30T01:03:14.886Z" }, - { url = "https://files.pythonhosted.org/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", size = 268423, upload-time = "2026-01-30T01:03:15.936Z" }, - { url = "https://files.pythonhosted.org/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", size = 266859, upload-time = "2026-01-30T01:03:17.458Z" }, - { url = "https://files.pythonhosted.org/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", size = 103520, upload-time = "2026-01-30T01:03:18.652Z" }, - { url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" }, - { url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" }, - { url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" }, - { url = "https://files.pythonhosted.org/packages/da/79/76e6d09ae19c99404656d7db9c35dfd20f2086f3eb6ecb496b5b31163bad/pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324", size = 271716, upload-time = "2026-01-30T01:03:23.633Z" }, - { url = "https://files.pythonhosted.org/packages/79/37/482e55fa1602e0a7ff012661d8c946bafdc05e480ea5a32f4f7e336d4aa9/pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9", size = 104539, upload-time = "2026-01-30T01:03:24.788Z" }, - { url = "https://files.pythonhosted.org/packages/30/e8/20e7db907c23f3d63b0be3b8a4fd1927f6da2395f5bcc7f72242bb963dfe/pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb", size = 168474, upload-time = "2026-01-30T01:03:26.428Z" }, - { url = "https://files.pythonhosted.org/packages/d6/81/88a95ee9fafdd8f5f3452107748fd04c24930d500b9aba9738f3ade642cc/pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3", size = 290473, upload-time = "2026-01-30T01:03:27.415Z" }, - { url = "https://files.pythonhosted.org/packages/cf/35/3aa899645e29b6375b4aed9f8d21df219e7c958c4c186b465e42ee0a06bf/pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975", size = 303485, upload-time = "2026-01-30T01:03:28.558Z" }, - { url = "https://files.pythonhosted.org/packages/52/a0/07907b6ff512674d9b201859f7d212298c44933633c946703a20c25e9d81/pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a", size = 306698, upload-time = "2026-01-30T01:03:29.653Z" }, - { url = "https://files.pythonhosted.org/packages/39/2a/cbbf9250020a4a8dd53ba83a46c097b69e5eb49dd14e708f496f548c6612/pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918", size = 116287, upload-time = "2026-01-30T01:03:30.912Z" }, - { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, -] - [[package]] name = "pywin32-ctypes" version = "0.2.3" @@ -1921,6 +1821,70 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, ] +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + [[package]] name = "qrcode" version = "8.2" @@ -2006,6 +1970,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7", size = 7676, upload-time = "2025-12-17T18:25:33.098Z" }, ] +[[package]] +name = "ruff" +version = "0.15.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/8a/8bce2894573e9dae6ff4d77fe34ad727d79b9e6238ad288c5638990d90f6/ruff-0.15.14.tar.gz", hash = "sha256:48e866b165be4a9bdbf310f7d3c9a07edef2fe8cd63ffeb4e00bb590506ebf9f", size = 4700910, upload-time = "2026-05-21T14:34:55.177Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/c8/74a92c6ff9fcfb4f1f947126d3ebee8389276e161ecc85de5bda7cda51bd/ruff-0.15.14-py3-none-linux_armv6l.whl", hash = "sha256:8dd2db9416e487c8d4b01fa7056bb02c4d05969d4f8d17a08c229c2f4ff3c108", size = 10739177, upload-time = "2026-05-21T14:34:37.332Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/254a35c20acc38a7223c9d2d594af12e794432464f2cdeb52af1dc4a892d/ruff-0.15.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:be4ff55af755bd71a00ab3dc6bd7ffc467bd76e0df6881e286c2e3d23e8fb43b", size = 11144969, upload-time = "2026-05-21T14:34:43.978Z" }, + { url = "https://files.pythonhosted.org/packages/56/9e/d13e40f83b8d0a94430e6778ce1d94a43b38cf2efe63278bdd2b4c65abbf/ruff-0.15.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:48d5909d7d06276ce7dde6d32bfa4b0d4cb2651145cd8ee4b440722cbc77832f", size = 10478207, upload-time = "2026-05-21T14:34:48.378Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f1/b15a7839fa4f332f8acec78e20564f26bb2d866e3d21710b877fd0263000/ruff-0.15.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca8cbfa94c4f90984a67561978602746d4cd27103568f745fa90eee3f0d4107d", size = 10818459, upload-time = "2026-05-21T14:34:22.318Z" }, + { url = "https://files.pythonhosted.org/packages/45/33/53d651177f84f94b400a0e27f8824eeada3dddc9d5ee8aeb048f4352a520/ruff-0.15.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a6bbc0333f1ab053423bcbf6226477d266ca7cec7738c4c8e3f55647803f3c4", size = 10541800, upload-time = "2026-05-21T14:34:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a6/868f87e0bf9786ed24b5d0d0ad8676b8a94fd1912f42cddf9cfc7857818a/ruff-0.15.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a24a4f7605d7003a6674d4387651effd939dead3fddd0f36561eb77a9a2e542", size = 11342149, upload-time = "2026-05-21T14:34:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/a7/8b/38cd5c19faffdcc05a408d2b78edccc69492ab9720eadb49ea15ef80d768/ruff-0.15.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:049b5326e53ed80978f2fc041a280603f69dd6b0c95464342a2bb4572d9d9e2f", size = 12212563, upload-time = "2026-05-21T14:34:28.579Z" }, + { url = "https://files.pythonhosted.org/packages/3e/4d/a3c5b874a556d5731e3e657aaf04311bb76f0a5c3ec220ed43051be6b64b/ruff-0.15.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4ed42e6696c8dfa5f06728e6441993901f548eb92d73bc472cb5a38d1395fbf", size = 11493299, upload-time = "2026-05-21T14:34:41.836Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c0/56472c251d09858a53e51efbd485b09e1995d8731668b76d52e5dd6ee0f1/ruff-0.15.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:715c543cf450c4888251f91c52f1942a800541d9bddd7ac060aa4e6b77ae7cba", size = 11455931, upload-time = "2026-05-21T14:34:57.276Z" }, + { url = "https://files.pythonhosted.org/packages/2c/4a/e2e7b4d8dbf233d4eace59c75bc3435fa6d8bd3bae82d351d4e4300c0fd1/ruff-0.15.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ebab6013ec887d439d8b7593737a0a4ffb06d45d209d4e4bf2e92813082d3f", size = 11400794, upload-time = "2026-05-21T14:34:39.773Z" }, + { url = "https://files.pythonhosted.org/packages/97/c7/83c0539fe34c3e09136204d1e75d6052492364e0b3cb05e9465423f567d7/ruff-0.15.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:49072d36abdbe97a8dd7f480afe9c675699c0c495d4c84076e2c1203c4550581", size = 10804759, upload-time = "2026-05-21T14:34:31.045Z" }, + { url = "https://files.pythonhosted.org/packages/86/a6/18f2bfc095a2ab4a78745644e428205532ce6653a5d0fa8501572891534d/ruff-0.15.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:958522aee105068640c2c2ceae08f413ae44d922f52a1374ac13d6a96032fc93", size = 10539517, upload-time = "2026-05-21T14:34:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/54/3a/5a8b3b69c654d4e4bf1d246ac5b49cbcdac6eaab6905925f8915f31e3b80/ruff-0.15.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f3707da619a143a2e8830e2abab8224478d69ace2d28cb6c20543ae97c36bf61", size = 11065169, upload-time = "2026-05-21T14:34:24.484Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c5/8864e4e7925b836ea354b31d57641ec03830564e281a8b6f061f8c3e0ec1/ruff-0.15.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:bb01d645694e3ec0102105d07ef2d53703970407d59c04e59d3ba0b7a1d53553", size = 11560214, upload-time = "2026-05-21T14:34:50.975Z" }, + { url = "https://files.pythonhosted.org/packages/36/38/012bf76752e1f89ed50b77b99532d90f3a3e287bc7918e1fc0948ac866ac/ruff-0.15.14-py3-none-win32.whl", hash = "sha256:6d0c1ad2a0ab718d39b6d8fd2217981ce4d625cd96a720095f798fb47d8b13e6", size = 10805548, upload-time = "2026-05-21T14:34:33.453Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/4ea2c170f10ad760fff2a5250beb18897719dc8b52b53a24cddbb9dd3f19/ruff-0.15.14-py3-none-win_amd64.whl", hash = "sha256:802342981e056db3851a7836e5b070f8f15f67d4a685ae2a6160939d364b2902", size = 11939523, upload-time = "2026-05-21T14:34:18.077Z" }, + { url = "https://files.pythonhosted.org/packages/62/d5/bc97ff895ec35cf3925d4bd60f3b39d822f377a446906ec9bcc87405e59b/ruff-0.15.14-py3-none-win_arm64.whl", hash = "sha256:ff47b90a9ef6a40c9e2f3b479c1fb78531adf055b94c1eba0a7ba04b31951826", size = 11208607, upload-time = "2026-05-21T14:34:26.525Z" }, +] + [[package]] name = "secretstorage" version = "3.5.0" @@ -2316,6 +2305,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] +[[package]] +name = "virtualenv" +version = "21.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/15/ba/1f6e8c957e4932be060dcdc482d339c12e0216351478add3645cdaa53c05/virtualenv-21.3.3.tar.gz", hash = "sha256:f5bda277e553b1c2b3c1a8debfc30496e1288cc93ce6b7b71b3280047e317328", size = 7613784, upload-time = "2026-05-13T18:01:30.19Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/34/a9dbe051de88a63eb7408ea66630bac38e72f7f6077d4be58737106860d9/virtualenv-21.3.3-py3-none-any.whl", hash = "sha256:7d5987d8369e098e41406efb780a3d4ca79280097293899e351a6407ee153ab3", size = 7594554, upload-time = "2026-05-13T18:01:27.815Z" }, +] + [[package]] name = "websockets" version = "16.0"