diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 67a71db..6128efa 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,4 +1,4 @@ -name: Upload Python Package +name: Publish on: release: @@ -8,77 +8,57 @@ on: 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 +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false - - 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: +jobs: + package: + name: Build package runs-on: ubuntu-latest - needs: release-checks steps: - - uses: actions/checkout@v4 - with: - persist-credentials: false + - name: Checkout repository + uses: actions/checkout@v6 - - name: Set up uv - uses: astral-sh/setup-uv@v4 + - name: Install uv + uses: astral-sh/setup-uv@v8.1.0 with: - python-version: "3.10" enable-cache: true - - name: Build release distributions + - name: Build package distributions run: uv build - - name: Check distributions + - name: Validate package distributions run: uv run twine check dist/* - - name: Upload distributions + - name: Upload package distributions uses: actions/upload-artifact@v4 with: - name: release-dists + name: package-distributions path: dist/ - pypi-publish: + publish: + name: Publish to PyPI runs-on: ubuntu-latest - needs: release-build + needs: [package] + environment: name: pypi - url: https://pypi.org/project/maxapi-python/ permissions: contents: read id-token: write steps: - - name: Retrieve release distributions + - name: Download package distributions uses: actions/download-artifact@v4 with: - name: release-dists + name: package-distributions path: dist/ - - name: Publish release distributions to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + - name: Install uv + uses: astral-sh/setup-uv@v8.1.0 + + - name: Publish package to PyPI + run: uv publish diff --git a/docs/index.rst b/docs/index.rst index fd6755a..3d2463e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -22,6 +22,7 @@ PyMax - асинхронная Python-библиотека для Max API. Он :maxdepth: 1 :caption: Новости + release-2-1-2 release-2-1-1 release-2-1-0 diff --git a/docs/release-2-1-2.rst b/docs/release-2-1-2.rst new file mode 100644 index 0000000..5b1aa3d --- /dev/null +++ b/docs/release-2-1-2.rst @@ -0,0 +1,32 @@ +PyMax 2.1.2 +=========== + +Изменения относительно ``2.1.1``. + +Добавлено +--------- + +* ``get_bot_init_data()`` теперь можно вызвать без ``chat_id`` для сценариев, + где Max запускает web app вне конкретного чата. + +Исправлено +---------- + +* ``ExtraConfig.request_timeout`` снова применяется к API-запросам по + умолчанию, а явный ``timeout`` в низкоуровневом вызове сохраняет приоритет. +* Login-ответ без нового ``token`` больше не ломает запуск клиента и не + перезаписывает сохраненный токен пустым значением. +* ``FolderList`` больше не переопределяет pydantic-итератор и не ломает + ``dict(...)`` / стандартную сериализацию модели. + +Изменилось +---------- + +* Publish workflow упрощен: сборка, проверка дистрибутивов и публикация в PyPI + теперь разделены на понятные шаги с artifact handoff. + +Миграция +-------- + +* Если код итерировал ``FolderList`` напрямую, замените это на + ``folder_list.folders``. diff --git a/pyproject.toml b/pyproject.toml index f33c1f3..b19b50b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "maxapi-python" -version = "2.1.1" +version = "2.1.2" description = "Python wrapper для API мессенджера Max" readme = "README.md" requires-python = ">=3.10" diff --git a/src/pymax/__init__.py b/src/pymax/__init__.py index 90e2eb6..546d722 100644 --- a/src/pymax/__init__.py +++ b/src/pymax/__init__.py @@ -1,4 +1,4 @@ -__version__ = "2.1.1" +__version__ = "2.1.2" from .auth import ( diff --git a/src/pymax/api/bots/payloads.py b/src/pymax/api/bots/payloads.py index 189f957..34a99a3 100644 --- a/src/pymax/api/bots/payloads.py +++ b/src/pymax/api/bots/payloads.py @@ -3,5 +3,5 @@ class RequestInitDataPayload(CamelModel): bot_id: int - chat_id: int + chat_id: int | None = None start_param: str | None = None diff --git a/src/pymax/api/bots/service.py b/src/pymax/api/bots/service.py index a672bc3..ecb5c9e 100644 --- a/src/pymax/api/bots/service.py +++ b/src/pymax/api/bots/service.py @@ -23,13 +23,9 @@ def __init__(self, app: App) -> None: async def get_init_data( self, bot_id: int, - chat_id: int, + chat_id: int | None = None, 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() - ) + 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/app.py b/src/pymax/app.py index 05b46fb..1b1347b 100644 --- a/src/pymax/app.py +++ b/src/pymax/app.py @@ -33,9 +33,7 @@ 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 @@ -76,18 +74,14 @@ 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()) @@ -108,9 +102,7 @@ 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( @@ -135,7 +127,7 @@ async def start(self) -> None: self.config.device.user_agent, ) - if response.token != self.session.token: + if response.token is not None and response.token != self.session.token: await self.store.update_token(self.session.token, response.token) self.session.token = response.token @@ -189,7 +181,7 @@ async def invoke( opcode: int, payload: dict[str, Any], cmd: int = Command.REQUEST, - timeout: float | None = 30.0, + timeout: float | None = None, compress: bool = False, ) -> InboundFrame: seq = self.connection.next_seq() @@ -211,10 +203,11 @@ async def invoke( payload_keys, ) 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 [] + request_timeout = ( + self.config.request_timeout if timeout is None else timeout ) + response = await self.connection.request(frame, timeout=request_timeout) + 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/types/domain/folder.py b/src/pymax/types/domain/folder.py index 4a0b6ac..ce14255 100644 --- a/src/pymax/types/domain/folder.py +++ b/src/pymax/types/domain/folder.py @@ -1,8 +1,6 @@ -from collections.abc import Iterator from typing import Any from pydantic import Field -from typing_extensions import override from .base import CamelModel @@ -68,7 +66,3 @@ class FolderList(CamelModel): folders: list[Folder] = Field(default_factory=list) all_filter_exclude_folders: list[Any] = Field(default_factory=list) folder_sync: int = 0 - - @override - def __iter__(self) -> Iterator[Folder]: # pyright: ignore[reportIncompatibleMethodOverride] - yield from self.folders diff --git a/src/pymax/types/domain/login.py b/src/pymax/types/domain/login.py index 35cef94..91ef2bf 100644 --- a/src/pymax/types/domain/login.py +++ b/src/pymax/types/domain/login.py @@ -16,11 +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 + token: str | None = None time: int | None = None config: LoginConfig | None = None @@ -29,19 +27,9 @@ 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/tests/api/test_chat_user_self_session_services.py b/tests/api/test_chat_user_self_session_services.py index f93dd5d..156e1b1 100644 --- a/tests/api/test_chat_user_self_session_services.py +++ b/tests/api/test_chat_user_self_session_services.py @@ -345,7 +345,7 @@ async def test_self_service_profile_photo_folders_and_logout() -> None: assert created.folder is not None assert created.folder.title == "Work" - assert [folder.id for folder in folders] == ["folder-1"] + assert [folder.id for folder in folders.folders] == ["folder-1"] assert updated.folder is not None assert updated.folder.title == "New" assert deleted.folder_sync == 4 diff --git a/tests/app/test_app_runtime.py b/tests/app/test_app_runtime.py index 56d4b07..de2096e 100644 --- a/tests/app/test_app_runtime.py +++ b/tests/app/test_app_runtime.py @@ -150,3 +150,21 @@ async def test_app_invoke_turns_error_frames_into_api_error() -> None: assert exc_info.value.error == "rate_limited" assert exc_info.value.opcode == Opcode.PING assert connection.sent[0][0].seq == 0 + + +@pytest.mark.asyncio +async def test_app_invoke_uses_config_timeout_and_allows_override() -> None: + store = RuntimeStore( + SessionInfo(token="token", device_id="dev", phone="+7") + ) + config = make_config().model_copy( + update={"request_timeout": 12.5, "store": store} + ) + connection = RuntimeConnection([frame({}), frame({})]) + app: App[object] = App(connection, config, StaticAuthFlow()) + + await app.invoke(Opcode.PING, {"interactive": True}) + await app.invoke(Opcode.PING, {"interactive": True}, timeout=3.0) + + assert connection.sent[0][1] == 12.5 + assert connection.sent[1][1] == 3.0 diff --git a/uv.lock b/uv.lock index 238d90f..470b2da 100644 --- a/uv.lock +++ b/uv.lock @@ -1033,7 +1033,7 @@ wheels = [ [[package]] name = "maxapi-python" -version = "2.1.1" +version = "2.1.2" source = { editable = "." } dependencies = [ { name = "aiofiles" },