maxapi-sdk 0.12.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. maxapi/__init__.py +49 -0
  2. maxapi/bot.py +674 -0
  3. maxapi/builders/__init__.py +23 -0
  4. maxapi/builders/keyboards.py +133 -0
  5. maxapi/builders/media.py +81 -0
  6. maxapi/callback_schema.py +140 -0
  7. maxapi/client/__init__.py +3 -0
  8. maxapi/client/default.py +89 -0
  9. maxapi/compat/__init__.py +15 -0
  10. maxapi/connection/__init__.py +3 -0
  11. maxapi/connection/base.py +136 -0
  12. maxapi/dispatcher.py +487 -0
  13. maxapi/exceptions/__init__.py +3 -0
  14. maxapi/exceptions/max.py +45 -0
  15. maxapi/filters/__init__.py +25 -0
  16. maxapi/filters/base.py +73 -0
  17. maxapi/filters/command.py +28 -0
  18. maxapi/filters/common.py +78 -0
  19. maxapi/filters/text.py +71 -0
  20. maxapi/fsm/__init__.py +17 -0
  21. maxapi/fsm/context.py +41 -0
  22. maxapi/fsm/filters.py +30 -0
  23. maxapi/fsm/middleware.py +42 -0
  24. maxapi/fsm/state.py +33 -0
  25. maxapi/fsm/storage/__init__.py +4 -0
  26. maxapi/fsm/storage/base.py +30 -0
  27. maxapi/fsm/storage/memory.py +38 -0
  28. maxapi/middlewares/__init__.py +3 -0
  29. maxapi/middlewares/base.py +34 -0
  30. maxapi/plugins/__init__.py +3 -0
  31. maxapi/plugins/base.py +19 -0
  32. maxapi/py.typed +1 -0
  33. maxapi/runners/__init__.py +4 -0
  34. maxapi/runners/polling.py +55 -0
  35. maxapi/runners/webhook.py +72 -0
  36. maxapi/transport/__init__.py +13 -0
  37. maxapi/transport/client.py +239 -0
  38. maxapi/transport/config.py +81 -0
  39. maxapi/transport/errors.py +45 -0
  40. maxapi/types/__init__.py +73 -0
  41. maxapi/types/base.py +9 -0
  42. maxapi/types/bot_mixin.py +14 -0
  43. maxapi/types/models.py +308 -0
  44. maxapi_sdk-0.12.0.dist-info/METADATA +270 -0
  45. maxapi_sdk-0.12.0.dist-info/RECORD +48 -0
  46. maxapi_sdk-0.12.0.dist-info/WHEEL +5 -0
  47. maxapi_sdk-0.12.0.dist-info/licenses/LICENSE +21 -0
  48. maxapi_sdk-0.12.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,239 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ from dataclasses import dataclass
6
+ from email.utils import parsedate_to_datetime
7
+ from typing import Any
8
+
9
+ from aiohttp import (
10
+ ClientConnectionError,
11
+ ClientPayloadError,
12
+ ClientResponse,
13
+ ClientSession,
14
+ ContentTypeError,
15
+ FormData,
16
+ TCPConnector,
17
+ )
18
+
19
+ from ..exceptions.max import InvalidToken, MaxApiError, MaxConnection
20
+ from .config import TransportConfig
21
+ from .errors import RateLimitExceededError, ResponseDecodeError, ServerResponseError
22
+
23
+
24
+ @dataclass(slots=True)
25
+ class TransportResult:
26
+ raw: Any
27
+ parsed: Any
28
+ status: int
29
+ headers: dict[str, str]
30
+
31
+
32
+ class MaxApiTransport:
33
+ """Надёжный HTTP-клиент для MAX API."""
34
+
35
+ def __init__(
36
+ self,
37
+ *,
38
+ base_url: str,
39
+ headers: dict[str, str] | None = None,
40
+ config: TransportConfig,
41
+ session: ClientSession | None = None,
42
+ ) -> None:
43
+ self._base_url = base_url.rstrip("/")
44
+ self._headers = dict(headers or {})
45
+ self._config = config
46
+ self._session = session
47
+ self._owns_session = False
48
+
49
+ @property
50
+ def session(self) -> ClientSession | None:
51
+ return self._session
52
+
53
+ async def close(self) -> None:
54
+ if self._owns_session and self._session is not None and not self._session.closed:
55
+ await self._session.close()
56
+
57
+ async def request(
58
+ self,
59
+ *,
60
+ method: str,
61
+ path: str,
62
+ model: Any = None,
63
+ **kwargs: Any,
64
+ ) -> TransportResult:
65
+ retry_policy = self._config.retry_policy
66
+ normalized_method = method.upper()
67
+ last_error: Exception | None = None
68
+
69
+ for attempt in range(1, retry_policy.attempts + 1):
70
+ response: ClientResponse | None = None
71
+ try:
72
+ session = await self._ensure_session()
73
+ url = path if path.startswith("http") else f"{self._base_url}{path}"
74
+ response = await session.request(method=normalized_method, url=url, **kwargs)
75
+ return await self._handle_response(response=response, model=model)
76
+ except InvalidToken:
77
+ raise
78
+ except RateLimitExceededError as exc:
79
+ last_error = exc
80
+ if attempt >= retry_policy.attempts:
81
+ raise MaxApiError(
82
+ code=exc.status,
83
+ raw=exc.payload,
84
+ message=exc.message,
85
+ headers=exc.headers,
86
+ ) from exc
87
+ await asyncio.sleep(
88
+ retry_policy.delay_for_attempt(attempt, retry_after=exc.retry_after)
89
+ )
90
+ except ServerResponseError as exc:
91
+ last_error = exc
92
+ if attempt >= retry_policy.attempts:
93
+ raise MaxApiError(
94
+ code=exc.status,
95
+ raw=exc.payload,
96
+ message=exc.message,
97
+ headers=exc.headers,
98
+ ) from exc
99
+ await asyncio.sleep(retry_policy.delay_for_attempt(attempt))
100
+ except (ClientConnectionError, ClientPayloadError, asyncio.TimeoutError) as exc:
101
+ last_error = exc
102
+ if attempt >= retry_policy.attempts or not retry_policy.allows(normalized_method):
103
+ raise MaxConnection(f"Ошибка при отправке запроса: {exc}") from exc
104
+ await asyncio.sleep(retry_policy.delay_for_attempt(attempt))
105
+ finally:
106
+ if response is not None and not response.closed:
107
+ response.release()
108
+
109
+ if last_error is not None:
110
+ raise MaxConnection(f"Ошибка при отправке запроса: {last_error}") from last_error
111
+ raise MaxConnection("Ошибка при отправке запроса: неизвестная ошибка")
112
+
113
+ async def upload(self, *, url: str, data: FormData) -> str:
114
+ session = await self._ensure_session(upload_mode=True)
115
+ try:
116
+ async with session.post(url=url, data=data) as response:
117
+ text = await response.text()
118
+ if response.status >= 400:
119
+ payload = self._try_parse_json_text(text)
120
+ raise MaxApiError(
121
+ code=response.status,
122
+ raw=payload,
123
+ message="Ошибка загрузки файла в MAX",
124
+ headers=dict(response.headers),
125
+ )
126
+ return text
127
+ except ClientConnectionError as exc:
128
+ raise MaxConnection(f"Ошибка при загрузке файла: {exc}") from exc
129
+
130
+ async def _ensure_session(self, *, upload_mode: bool = False) -> ClientSession:
131
+ if self._session is not None and not self._session.closed:
132
+ return self._session
133
+
134
+ session_kwargs = dict(self._config.session_kwargs)
135
+ session_kwargs.setdefault("timeout", self._config.timeout)
136
+ session_kwargs.setdefault("headers", self._headers)
137
+ session_kwargs.setdefault(
138
+ "connector",
139
+ TCPConnector(
140
+ limit=session_kwargs.pop("connector_limit", 100),
141
+ limit_per_host=session_kwargs.pop("connector_limit_per_host", 30),
142
+ ),
143
+ )
144
+
145
+ if upload_mode:
146
+ session_kwargs.pop("headers", None)
147
+
148
+ self._session = ClientSession(**session_kwargs)
149
+ self._owns_session = True
150
+ return self._session
151
+
152
+ async def _handle_response(
153
+ self,
154
+ *,
155
+ response: ClientResponse,
156
+ model: Any,
157
+ ) -> TransportResult:
158
+ headers = dict(response.headers)
159
+ raw = await self._decode_response(response)
160
+
161
+ if response.status == 401:
162
+ raise InvalidToken("Неверный токен!", raw=raw, headers=headers)
163
+ if response.status == 429:
164
+ retry_after = self._extract_retry_after(headers)
165
+ raise RateLimitExceededError(
166
+ status=response.status,
167
+ payload=raw,
168
+ headers=headers,
169
+ retry_after=retry_after,
170
+ message="API MAX вернул 429 Too Many Requests",
171
+ )
172
+ if 500 <= response.status <= 599:
173
+ raise ServerResponseError(
174
+ status=response.status,
175
+ payload=raw,
176
+ headers=headers,
177
+ message="API MAX вернул серверную ошибку",
178
+ )
179
+ if response.status >= 400:
180
+ raise MaxApiError(code=response.status, raw=raw, headers=headers)
181
+
182
+ parsed = self._parse_model(model=model, raw=raw)
183
+ return TransportResult(raw=raw, parsed=parsed, status=response.status, headers=headers)
184
+
185
+ async def _decode_response(self, response: ClientResponse) -> Any:
186
+ if response.status == 204:
187
+ return {}
188
+
189
+ content_type = response.headers.get("Content-Type", "")
190
+ if "application/json" in content_type.lower():
191
+ try:
192
+ return await response.json(content_type=None)
193
+ except (ContentTypeError, json.JSONDecodeError) as exc:
194
+ text = await response.text()
195
+ raise ResponseDecodeError(text=text, content_type=content_type) from exc
196
+
197
+ text = await response.text()
198
+ return self._try_parse_json_text(text)
199
+
200
+ def _parse_model(self, *, model: Any, raw: Any) -> Any:
201
+ if model is None:
202
+ return raw
203
+ if hasattr(model, "model_validate"):
204
+ return model.model_validate(raw)
205
+ if hasattr(model, "parse_obj"):
206
+ return model.parse_obj(raw)
207
+ if callable(model):
208
+ return model(**raw)
209
+ return raw
210
+
211
+ @staticmethod
212
+ def _try_parse_json_text(text: str) -> Any:
213
+ if not text.strip():
214
+ return {}
215
+ try:
216
+ return json.loads(text)
217
+ except json.JSONDecodeError:
218
+ return {"text": text}
219
+
220
+ @staticmethod
221
+ def _extract_retry_after(headers: dict[str, str]) -> float | None:
222
+ retry_after = headers.get("Retry-After")
223
+ if retry_after is None:
224
+ return None
225
+ try:
226
+ return float(retry_after)
227
+ except ValueError:
228
+ pass
229
+ try:
230
+ parsed = parsedate_to_datetime(retry_after)
231
+ except (TypeError, ValueError):
232
+ return None
233
+ now = parsed.tzinfo and parsed.now(parsed.tzinfo) or None
234
+ if now is None:
235
+ return None
236
+ delay = (parsed - now).total_seconds()
237
+ if delay <= 0:
238
+ return None
239
+ return delay
@@ -0,0 +1,81 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+ from aiohttp import ClientTimeout
7
+
8
+
9
+ @dataclass(slots=True)
10
+ class RetryPolicy:
11
+ """Политика повторов HTTP-запросов."""
12
+
13
+ attempts: int = 5
14
+ initial_delay: float = 0.5
15
+ backoff: float = 2.0
16
+ max_delay: float = 8.0
17
+ respect_retry_after: bool = True
18
+ retry_statuses: tuple[int, ...] = (408, 425, 429, 500, 502, 503, 504)
19
+ retry_methods: tuple[str, ...] = (
20
+ "DELETE",
21
+ "GET",
22
+ "HEAD",
23
+ "OPTIONS",
24
+ "PATCH",
25
+ "POST",
26
+ "PUT",
27
+ )
28
+
29
+ def delay_for_attempt(
30
+ self,
31
+ attempt_index: int,
32
+ *,
33
+ retry_after: float | None = None,
34
+ ) -> float:
35
+ if self.respect_retry_after and retry_after is not None and retry_after > 0:
36
+ return min(retry_after, self.max_delay)
37
+ delay = self.initial_delay * (self.backoff ** max(attempt_index - 1, 0))
38
+ return min(delay, self.max_delay)
39
+
40
+ def allows(self, method: str, status: int | None = None) -> bool:
41
+ normalized_method = method.upper()
42
+ if normalized_method not in self.retry_methods:
43
+ return False
44
+ if status is None:
45
+ return True
46
+ return status in self.retry_statuses
47
+
48
+
49
+ @dataclass(slots=True)
50
+ class TransportConfig:
51
+ """Конфигурация транспортного клиента."""
52
+
53
+ timeout: ClientTimeout
54
+ session_kwargs: dict[str, Any]
55
+ retry_policy: RetryPolicy
56
+
57
+ @classmethod
58
+ def from_default_connection(cls, connection: Any) -> "TransportConfig":
59
+ retry_policy = RetryPolicy(
60
+ attempts=getattr(connection, "request_retries", 5),
61
+ initial_delay=getattr(connection, "request_retry_delay", 0.5),
62
+ backoff=getattr(connection, "request_retry_backoff", 2.0),
63
+ max_delay=getattr(connection, "request_retry_max_delay", 8.0),
64
+ respect_retry_after=getattr(connection, "respect_retry_after", True),
65
+ retry_statuses=tuple(
66
+ getattr(connection, "retry_statuses", (408, 425, 429, 500, 502, 503, 504))
67
+ ),
68
+ retry_methods=tuple(
69
+ getattr(
70
+ connection,
71
+ "retry_methods",
72
+ ("DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"),
73
+ )
74
+ ),
75
+ )
76
+ session_kwargs = dict(getattr(connection, "kwargs", {}))
77
+ return cls(
78
+ timeout=connection.timeout,
79
+ session_kwargs=session_kwargs,
80
+ retry_policy=retry_policy,
81
+ )
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+
7
+ @dataclass(slots=True)
8
+ class TransportResponseError(Exception):
9
+ """Базовая ошибка transport-слоя."""
10
+
11
+ status: int
12
+ payload: Any
13
+ message: str | None = None
14
+ headers: dict[str, str] | None = None
15
+
16
+ def __str__(self) -> str:
17
+ if self.message:
18
+ return self.message
19
+ return f"Transport error status={self.status}: {self.payload!r}"
20
+
21
+
22
+ @dataclass(slots=True)
23
+ class RateLimitExceededError(TransportResponseError):
24
+ """Ошибка 429 Too Many Requests."""
25
+
26
+ retry_after: float | None = None
27
+
28
+
29
+ @dataclass(slots=True)
30
+ class ServerResponseError(TransportResponseError):
31
+ """Ошибка 5xx от API MAX."""
32
+
33
+
34
+ @dataclass(slots=True)
35
+ class ResponseDecodeError(Exception):
36
+ """Ошибка декодирования ответа сервера."""
37
+
38
+ text: str
39
+ content_type: str | None = None
40
+
41
+ def __str__(self) -> str:
42
+ return (
43
+ "Не удалось декодировать ответ сервера MAX как JSON. "
44
+ f"content_type={self.content_type!r}"
45
+ )
@@ -0,0 +1,73 @@
1
+ from .base import ApiModel
2
+ from .models import (
3
+ AddAdminsRequest,
4
+ AddMembersRequest,
5
+ AnswerCallbackRequest,
6
+ BotCommand,
7
+ CallbackPayload,
8
+ Chat,
9
+ ChatAdmin,
10
+ ChatMember,
11
+ ChatsPage,
12
+ EditMessageRequest,
13
+ EditMessageResponse,
14
+ Image,
15
+ MembersPage,
16
+ Message,
17
+ MessageBody,
18
+ MessageList,
19
+ PinMessageRequest,
20
+ Recipient,
21
+ SendMessageRequest,
22
+ SendMessageResponse,
23
+ SenderAction,
24
+ Subscription,
25
+ SubscriptionsPage,
26
+ SuccessResponse,
27
+ TextFormat,
28
+ Update,
29
+ UpdateType,
30
+ UpdatesPage,
31
+ UploadResponse,
32
+ UploadType,
33
+ User,
34
+ VideoInfo,
35
+ WebhookRequest,
36
+ )
37
+
38
+ __all__ = [
39
+ "AddAdminsRequest",
40
+ "AddMembersRequest",
41
+ "AnswerCallbackRequest",
42
+ "ApiModel",
43
+ "BotCommand",
44
+ "CallbackPayload",
45
+ "Chat",
46
+ "ChatAdmin",
47
+ "ChatMember",
48
+ "ChatsPage",
49
+ "EditMessageRequest",
50
+ "EditMessageResponse",
51
+ "Image",
52
+ "MembersPage",
53
+ "Message",
54
+ "MessageBody",
55
+ "MessageList",
56
+ "PinMessageRequest",
57
+ "Recipient",
58
+ "SendMessageRequest",
59
+ "SendMessageResponse",
60
+ "SenderAction",
61
+ "Subscription",
62
+ "SubscriptionsPage",
63
+ "SuccessResponse",
64
+ "TextFormat",
65
+ "Update",
66
+ "UpdateType",
67
+ "UpdatesPage",
68
+ "UploadResponse",
69
+ "UploadType",
70
+ "User",
71
+ "VideoInfo",
72
+ "WebhookRequest",
73
+ ]
maxapi/types/base.py ADDED
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+
3
+ from pydantic import BaseModel, ConfigDict
4
+
5
+
6
+ class ApiModel(BaseModel):
7
+ """Базовая модель SDK."""
8
+
9
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
@@ -0,0 +1,14 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ class BotMixin:
7
+ """Предоставляет безопасный доступ к экземпляру бота."""
8
+
9
+ bot: Any | None = None
10
+
11
+ def _ensure_bot(self) -> Any:
12
+ if self.bot is None:
13
+ raise RuntimeError("Экземпляр Bot не привязан к текущему объекту.")
14
+ return self.bot