Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ def skip_pydantic_members(

extensions = [
"sphinx.ext.autodoc",
"sphinx.ext.autosummary",
"sphinx.ext.napoleon",
"sphinx.ext.viewcode",
"sphinx.ext.intersphinx",
Expand All @@ -86,6 +87,8 @@ def skip_pydantic_members(

templates_path = ["_templates"]

autosummary_generate = True

exclude_patterns = [
"_build",
"Thumbs.db",
Expand All @@ -99,20 +102,24 @@ def skip_pydantic_members(
autodoc_default_options = {
"members": True,
"undoc-members": False,
"show-inheritance": False,
"show-inheritance": True,
"private-members": False,
"special-members": False,
"member-order": "bysource",
"exclude-members": (
"dict,json,parse_obj,parse_raw,schema,model_dump,model_validate,"
"model_json_schema,model_construct"
"dict,json,parse_obj,parse_raw,schema,schema_json,"
"copy,construct,from_orm,update_forward_refs,validate,"
"model_dump,model_dump_json,model_validate,model_validate_json,"
"model_validate_strings,model_json_schema,model_construct,"
"model_copy,model_rebuild,model_post_init,model_parametrized_name"
),
}


autodoc_typehints_format = "short"
autodoc_member_order = "bysource"
autodoc_typehints = "description"
autodoc_typehints_format = "short"
autodoc_class_signature = "separated"

# -- Napoleon ----------------------------------------------------------------

napoleon_google_docstring = True
Expand All @@ -131,7 +138,9 @@ def skip_pydantic_members(

# -- HTML --------------------------------------------------------------------

# html_theme = "shibuya"
html_theme = "furo"

html_title = "PyMax"
html_static_path = ["_static"]

Expand Down
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ PyMax - асинхронная Python-библиотека для Max API. Он
:maxdepth: 1
:caption: Новости

release-2-1-3
release-2-1-2
release-2-1-1
release-2-1-0
Expand Down
48 changes: 48 additions & 0 deletions docs/release-2-1-3.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
PyMax 2.1.3
===========

Изменения относительно ``2.1.2``.

Добавлено
---------

* ``UnknownAttachment`` для вложений с неизвестным ``_type``. Такие вложения
больше не ломают парсинг ``Message`` и сохраняют дополнительные поля
payload-а.

Исправлено
----------

* ``Message`` больше не падает на неизвестных типах вложений вроде
``UNSUPPORTED``.
* ``AudioAttachment`` принимает payload без ``duration`` и ``audioId``.
* ``VideoAttachment`` принимает payload без ``duration``.
* ``ElementAttributes.url`` и ``Element.length`` стали необязательными для
элементов, где Max не присылает эти поля.
* ``Photo(url=...)`` корректно определяет расширение и MIME type, если в URL
есть query string.
* При потере соединения ``App.started`` сбрасывается, ping-task отменяется, а
pending API-запросы очищаются без ``Future exception was never retrieved``.
* Reconnect/close после штатного сетевого обрыва стало меньше шуметь
exception-логами.

Изменилось
----------

* ``configure_logging()`` теперь уважает уже настроенный logging
host-приложения: PyMax не очищает чужие handler-ы и не добавляет свой
stderr-handler, если logging уже сконфигурирован.
* Если logging не настроен, PyMax по-прежнему включает pretty-логи из коробки.
* Для принудительного включения pretty-логов PyMax добавлен аргумент
``configure_logging(..., force=True)``.
* TCP msgpack decoder стал проще и подробнее логирует payload при ошибках
декодирования.

Миграция
--------

* Код на ``Client`` и ``WebClient`` обычно менять не нужно.
* Если приложение рассчитывало, что ``configure_logging()`` всегда заменяет
существующие handler-ы ``pymax``, передайте ``force=True``.
* Если код обрабатывал ``ValidationError`` для неизвестных вложений, теперь
вместо ошибки придет ``UnknownAttachment``.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "maxapi-python"
version = "2.1.2"
version = "2.1.3"
description = "Python wrapper для API мессенджера Max"
readme = "README.md"
requires-python = ">=3.10"
Expand Down
2 changes: 1 addition & 1 deletion src/pymax/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
__version__ = "2.1.2"
__version__ = "2.1.3"


from .auth import (
Expand Down
22 changes: 18 additions & 4 deletions src/pymax/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ def __init__(
self._telemetry = TelemetryService(self) if config.telemetry else None

self.connection.on_event = self.on_event
self.connection.on_close = self.on_connection_lost
logger.debug(
"app initialized session=%s work_dir=%s auth_flow=%s",
config.session_name,
Expand Down Expand Up @@ -174,6 +175,7 @@ async def close(self) -> None:
await self.dispatcher.stop_startup_tasks()
await self.connection.close()
await self.store.close()

self.started = False

async def invoke(
Expand Down Expand Up @@ -203,9 +205,7 @@ async def invoke(
payload_keys,
)
logger.debug("Request data=%s", frame.model_dump())
request_timeout = (
self.config.request_timeout if timeout is None else timeout
)
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(
Expand All @@ -231,9 +231,23 @@ async def _ping_loop(self) -> None:
except asyncio.CancelledError:
raise
except Exception as e:
logger.exception("ping loop failed; closing transport")
logger.warning("ping loop failed; closing transport: %s", e)
await self.connection.fail(ConnectionError(f"Ping failed: {e}"))

def on_connection_lost(self, exc: Exception | None = None) -> None:
if self.started:
logger.warning("connection lost; marking app as stopped: %s", exc)

self.started = False

task = self._ping_task
if task is None or task.done():
return

current_task = asyncio.current_task()
if task is not current_task:
task.cancel()

def _build_api_error(self, response: InboundFrame) -> ApiError:
try:
error = MaxApiError.model_validate(response.payload)
Expand Down
5 changes: 1 addition & 4 deletions src/pymax/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,7 @@ 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:
Expand Down
65 changes: 46 additions & 19 deletions src/pymax/connection/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,19 @@ def __init__(
transport: Transport,
protocol: BaseProtocol,
on_event: Callable[[InboundFrame], Awaitable[None]] | None = None,
on_close: Callable[[Exception | None], None] | None = None,
) -> None:
self.reader = reader
self.transport = transport
self.protocol = protocol
self.on_event = on_event
self.on_close = on_close

self.requests = PendingRequests()

self._is_open = False
self._connection_lost = False
self._close_reported = False
self._seq = -1

self._recv_task: asyncio.Task[None] | None = None
Expand All @@ -44,6 +47,7 @@ async def open(self) -> None:
await self.transport.connect()
self._is_open = True
self._connection_lost = False
self._close_reported = False

self._recv_task = asyncio.create_task(self._recv_loop())
logger.debug("receive loop started")
Expand Down Expand Up @@ -80,7 +84,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
self._mark_closed(exc)

async def send(self, frame: OutboundFrame) -> None:
if not self._is_open:
Expand Down Expand Up @@ -116,20 +120,38 @@ async def request(
)
await self.transport.send(raw)
return await asyncio.wait_for(future, timeout)
except Exception as e:
except asyncio.CancelledError:
self.requests.discard(frame.seq)
raise
except (ConnectionError, EOFError, OSError, TimeoutError) as e:
logger.warning(
"request failed seq=%s opcode=%s error=%s",
frame.seq,
frame.opcode,
e,
)
self.requests.discard(frame.seq)
raise
except Exception:
logger.exception(
"request failed seq=%s opcode=%s",
frame.seq,
frame.opcode,
)
self.requests.reject(frame.seq, e)
self.requests.discard(frame.seq)
raise

async def wait_closed(self) -> None:
if not self._recv_task:
return

await self._recv_task
try:
await self._recv_task
except Exception as e:
if self._connection_lost:
raise ConnectionError("Connection lost") from e
raise

if self._connection_lost:
raise ConnectionError("Connection lost")

Expand All @@ -147,27 +169,23 @@ async def _recv_loop(self) -> None:
await self._handle_inbound(model)

except EOFError:
exc = ConnectionError("Connection closed by the server")
logger.warning("connection closed by server")
self.requests.cancel_all(
exc=ConnectionError("Connection closed by the server")
)
self.requests.cancel_all(exc=exc)
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._mark_closed(exc)
except (ConnectionError, OSError, TimeoutError) as e:
exc = ConnectionError(f"Connection error: {e}")
logger.warning("connection closed while reading payload: %s", e)
self.requests.cancel_all(exc=exc)
self._connection_lost = True
self._is_open = False
raise e
self._mark_closed(exc)
except Exception as e:
exc = ConnectionError(f"Connection error: {e}")
logger.exception("connection receive loop failed")
self.requests.cancel_all(
exc=ConnectionError(f"Connection error: {e}")
)
self.requests.cancel_all(exc=exc)
self._connection_lost = True
self._is_open = False
self._mark_closed(exc)
raise e

async def _handle_inbound(self, frame: InboundFrame) -> None:
Expand Down Expand Up @@ -210,6 +228,15 @@ def next_seq(self) -> int:
self._seq = (self._seq + 1) % 0x10000
return self._seq

def _mark_closed(self, exc: Exception | None = None) -> None:
self._is_open = False
if self._close_reported:
return

self._close_reported = True
if self.on_close:
self.on_close(exc)

@property
def is_open(self) -> bool:
return self._is_open
6 changes: 4 additions & 2 deletions src/pymax/files/photo.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import mimetypes
from collections.abc import AsyncGenerator
from pathlib import Path
from urllib.parse import urlsplit

from .base import BaseFile
from .static import ALLOWED_EXTENSIONS
Expand Down Expand Up @@ -66,12 +67,13 @@ def validate_photo(self) -> tuple[str, str] | None:
raise ValueError(msg)
return (extension[1:], ("image/" + extension[1:]).lower())
if self.url:
extension = Path(self.url).suffix.lower()
url_path = urlsplit(self.url).path
extension = Path(url_path).suffix.lower()
if extension not in ALLOWED_EXTENSIONS:
msg = f"Invalid photo extension: {extension}. Allowed: {ALLOWED_EXTENSIONS}"
raise ValueError(msg)

mime_type = mimetypes.guess_type(self.url)[0]
mime_type = mimetypes.guess_type(url_path)[0]

if not mime_type or not mime_type.startswith("image/"):
msg = f"URL does not appear to be an image: {self.url}"
Expand Down
Loading