maxapi-python 1.2.5__py3-none-any.whl → 2.0.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 (169) hide show
  1. maxapi_python-2.0.0.dist-info/METADATA +217 -0
  2. maxapi_python-2.0.0.dist-info/RECORD +140 -0
  3. {maxapi_python-1.2.5.dist-info → maxapi_python-2.0.0.dist-info}/WHEEL +1 -1
  4. pymax/__init__.py +50 -105
  5. pymax/api/__init__.py +17 -0
  6. pymax/api/auth/__init__.py +1 -0
  7. pymax/api/auth/enums.py +17 -0
  8. pymax/api/auth/payloads.py +129 -0
  9. pymax/api/auth/service.py +313 -0
  10. pymax/api/auth/types.py +13 -0
  11. pymax/api/chats/__init__.py +8 -0
  12. pymax/api/chats/enums.py +27 -0
  13. pymax/api/chats/payloads.py +103 -0
  14. pymax/api/chats/service.py +277 -0
  15. pymax/api/facade.py +32 -0
  16. pymax/api/messages/__init__.py +1 -0
  17. pymax/api/messages/enums.py +17 -0
  18. pymax/api/messages/payloads.py +92 -0
  19. pymax/api/messages/service.py +337 -0
  20. pymax/api/models.py +13 -0
  21. pymax/api/response.py +123 -0
  22. pymax/api/self/__init__.py +2 -0
  23. pymax/api/self/enums.py +11 -0
  24. pymax/api/self/payloads.py +41 -0
  25. pymax/api/self/service.py +142 -0
  26. pymax/api/session/__init__.py +1 -0
  27. pymax/api/session/enums.py +10 -0
  28. pymax/api/session/payloads.py +76 -0
  29. pymax/api/session/service.py +72 -0
  30. pymax/api/uploads/__init__.py +1 -0
  31. pymax/api/uploads/models.py +49 -0
  32. pymax/api/uploads/payloads.py +25 -0
  33. pymax/api/uploads/service.py +458 -0
  34. pymax/api/users/__init__.py +2 -0
  35. pymax/api/users/enums.py +12 -0
  36. pymax/api/users/payloads.py +16 -0
  37. pymax/api/users/service.py +124 -0
  38. pymax/app.py +273 -0
  39. pymax/auth/__init__.py +25 -0
  40. pymax/auth/base.py +37 -0
  41. pymax/auth/email.py +0 -0
  42. pymax/auth/models.py +5 -0
  43. pymax/auth/providers.py +127 -0
  44. pymax/auth/qr.py +135 -0
  45. pymax/auth/service.py +25 -0
  46. pymax/auth/sms.py +122 -0
  47. pymax/base.py +204 -0
  48. pymax/client.py +106 -0
  49. pymax/client_web.py +83 -0
  50. pymax/config.py +215 -0
  51. pymax/connection/__init__.py +1 -0
  52. pymax/connection/connection.py +205 -0
  53. pymax/connection/pending.py +46 -0
  54. pymax/connection/readers/__init__.py +2 -0
  55. pymax/connection/readers/base.py +6 -0
  56. pymax/connection/readers/tcp.py +29 -0
  57. pymax/connection/readers/ws.py +14 -0
  58. pymax/dispatch/__init__.py +10 -0
  59. pymax/dispatch/dispatcher.py +222 -0
  60. pymax/dispatch/enums.py +12 -0
  61. pymax/dispatch/mapping.py +73 -0
  62. pymax/dispatch/resolvers.py +52 -0
  63. pymax/dispatch/router.py +216 -0
  64. pymax/exceptions.py +22 -89
  65. pymax/files/__init__.py +9 -0
  66. pymax/files/base.py +82 -0
  67. pymax/files/file.py +76 -0
  68. pymax/files/photo.py +108 -0
  69. pymax/files/static.py +10 -0
  70. pymax/files/video.py +74 -0
  71. pymax/formatting/__init__.py +0 -0
  72. pymax/formatting/markdown.py +217 -0
  73. pymax/infra/__init__.py +1 -0
  74. pymax/infra/auth.py +55 -0
  75. pymax/infra/base.py +15 -0
  76. pymax/infra/chat.py +240 -0
  77. pymax/infra/message.py +252 -0
  78. pymax/infra/protocol.py +9 -0
  79. pymax/infra/self.py +139 -0
  80. pymax/infra/user.py +107 -0
  81. pymax/logging.py +129 -0
  82. pymax/protocol/__init__.py +11 -0
  83. pymax/protocol/base.py +13 -0
  84. pymax/protocol/enums.py +180 -0
  85. pymax/protocol/models.py +33 -0
  86. pymax/protocol/tcp/__init__.py +1 -0
  87. pymax/protocol/tcp/compression.py +97 -0
  88. pymax/protocol/tcp/framing.py +68 -0
  89. pymax/protocol/tcp/payload.py +127 -0
  90. pymax/protocol/tcp/protocol.py +68 -0
  91. pymax/protocol/ws/__init__.py +1 -0
  92. pymax/protocol/ws/protocol.py +27 -0
  93. pymax/py.typed +0 -0
  94. pymax/routers.py +8 -0
  95. pymax/session/__init__.py +3 -0
  96. pymax/session/models.py +11 -0
  97. pymax/session/protocol.py +14 -0
  98. pymax/session/store.py +232 -0
  99. pymax/telemetry/__init__.py +3 -0
  100. pymax/telemetry/navigation.py +181 -0
  101. pymax/telemetry/payloads.py +142 -0
  102. pymax/telemetry/service.py +225 -0
  103. pymax/transport/__init__.py +0 -0
  104. pymax/transport/base.py +14 -0
  105. pymax/transport/tcp.py +93 -0
  106. pymax/transport/websocket.py +50 -0
  107. pymax/types/__init__.py +2 -0
  108. pymax/types/domain/__init__.py +11 -0
  109. pymax/types/domain/attachments/__init__.py +11 -0
  110. pymax/types/domain/attachments/audio.py +35 -0
  111. pymax/types/domain/attachments/call.py +26 -0
  112. pymax/types/domain/attachments/contact.py +32 -0
  113. pymax/types/domain/attachments/control.py +20 -0
  114. pymax/types/domain/attachments/enums.py +27 -0
  115. pymax/types/domain/attachments/file.py +56 -0
  116. pymax/types/domain/attachments/keyboards/__init__.py +1 -0
  117. pymax/types/domain/attachments/keyboards/inline.py +19 -0
  118. pymax/types/domain/attachments/photo.py +45 -0
  119. pymax/types/domain/attachments/share.py +29 -0
  120. pymax/types/domain/attachments/sticker.py +50 -0
  121. pymax/types/domain/attachments/video.py +90 -0
  122. pymax/types/domain/auth.py +161 -0
  123. pymax/types/domain/base.py +17 -0
  124. pymax/types/domain/chat.py +426 -0
  125. pymax/types/domain/element.py +24 -0
  126. pymax/types/domain/enums.py +24 -0
  127. pymax/types/domain/error.py +20 -0
  128. pymax/types/domain/folder.py +74 -0
  129. pymax/types/domain/login.py +35 -0
  130. pymax/types/domain/message.py +378 -0
  131. pymax/types/domain/name.py +20 -0
  132. pymax/types/domain/profile.py +15 -0
  133. pymax/types/domain/session.py +52 -0
  134. pymax/types/domain/sync.py +80 -0
  135. pymax/types/domain/user.py +117 -0
  136. pymax/types/events/__init__.py +3 -0
  137. pymax/types/events/file.py +5 -0
  138. pymax/types/events/message.py +37 -0
  139. pymax/types/events/video.py +5 -0
  140. maxapi_python-1.2.5.dist-info/METADATA +0 -202
  141. maxapi_python-1.2.5.dist-info/RECORD +0 -33
  142. pymax/core.py +0 -398
  143. pymax/crud.py +0 -96
  144. pymax/files.py +0 -138
  145. pymax/filters.py +0 -164
  146. pymax/formatter.py +0 -31
  147. pymax/formatting.py +0 -74
  148. pymax/interfaces.py +0 -558
  149. pymax/mixins/__init__.py +0 -40
  150. pymax/mixins/auth.py +0 -594
  151. pymax/mixins/channel.py +0 -130
  152. pymax/mixins/group.py +0 -458
  153. pymax/mixins/handler.py +0 -285
  154. pymax/mixins/message.py +0 -879
  155. pymax/mixins/scheduler.py +0 -28
  156. pymax/mixins/self.py +0 -259
  157. pymax/mixins/socket.py +0 -306
  158. pymax/mixins/telemetry.py +0 -118
  159. pymax/mixins/user.py +0 -219
  160. pymax/mixins/websocket.py +0 -151
  161. pymax/models.py +0 -8
  162. pymax/navigation.py +0 -187
  163. pymax/payloads.py +0 -403
  164. pymax/protocols.py +0 -123
  165. pymax/static/constant.py +0 -96
  166. pymax/static/enum.py +0 -231
  167. pymax/types.py +0 -1220
  168. pymax/utils.py +0 -90
  169. {maxapi_python-1.2.5.dist-info → maxapi_python-2.0.0.dist-info}/licenses/LICENSE +0 -0
pymax/config.py ADDED
@@ -0,0 +1,215 @@
1
+ from random import choice, randint
2
+ from uuid import uuid4
3
+
4
+ from pydantic import BaseModel, ConfigDict, Field
5
+
6
+ from pymax.api.session.enums import DeviceType
7
+ from pymax.api.session.payloads import (
8
+ DEFAULT_WEB_HEADER_USER_AGENT,
9
+ MobileUserAgentPayload,
10
+ )
11
+ from pymax.session import SessionStore, StoreProtocol
12
+ from pymax.types.domain.sync import SyncOverrides
13
+
14
+ APP_VERSIONS: tuple[tuple[str, int], ...] = (
15
+ ("26.14.1", 6686),
16
+ ("26.14.0", 6685),
17
+ ("26.13.0", 6683),
18
+ ("26.12.2", 6681),
19
+ ("26.12.1", 6679),
20
+ ("26.12.0", 6678),
21
+ ("26.11.3", 6680),
22
+ ("26.11.2", 6669),
23
+ ("26.11.1", 6665),
24
+ ("26.11.0", 6661),
25
+ )
26
+ ANDROID_DEVICES: tuple[tuple[str, str, str, str], ...] = (
27
+ ("Samsung SM-A525F", "Android 13", "405dpi 405dpi 1080x2400", "arm64-v8a"),
28
+ ("Samsung SM-A536B", "Android 14", "405dpi 405dpi 1080x2400", "arm64-v8a"),
29
+ ("Samsung SM-A546E", "Android 14", "405dpi 405dpi 1080x2340", "arm64-v8a"),
30
+ ("Samsung SM-G991B", "Android 14", "421dpi 421dpi 1080x2400", "arm64-v8a"),
31
+ ("Samsung SM-G998B", "Android 13", "515dpi 515dpi 1440x3200", "arm64-v8a"),
32
+ ("Samsung SM-S901B", "Android 14", "425dpi 425dpi 1080x2340", "arm64-v8a"),
33
+ ("Samsung SM-S911B", "Android 14", "425dpi 425dpi 1080x2340", "arm64-v8a"),
34
+ ("Xiaomi 2109119DG", "Android 13", "395dpi 395dpi 1080x2400", "arm64-v8a"),
35
+ ("Xiaomi 2201117TG", "Android 13", "395dpi 395dpi 1080x2400", "arm64-v8a"),
36
+ ("Xiaomi 2201123G", "Android 14", "526dpi 526dpi 1440x3200", "arm64-v8a"),
37
+ ("Xiaomi 2210132G", "Android 14", "446dpi 446dpi 1220x2712", "arm64-v8a"),
38
+ ("Xiaomi 23049PCD8G", "Android 14", "446dpi 446dpi 1220x2712", "arm64-v8a"),
39
+ ("Redmi 2201116TG", "Android 13", "395dpi 395dpi 1080x2400", "arm64-v8a"),
40
+ ("Redmi 22101316G", "Android 13", "395dpi 395dpi 1080x2400", "arm64-v8a"),
41
+ ("Redmi 23021RAA2Y", "Android 14", "395dpi 395dpi 1080x2400", "arm64-v8a"),
42
+ ("POCO 22011211G", "Android 13", "395dpi 395dpi 1080x2400", "arm64-v8a"),
43
+ ("POCO 23049PCD8G", "Android 14", "446dpi 446dpi 1220x2712", "arm64-v8a"),
44
+ ("Pixel 6", "Android 14", "411dpi 411dpi 1080x2400", "arm64-v8a"),
45
+ ("Pixel 6a", "Android 14", "429dpi 429dpi 1080x2400", "arm64-v8a"),
46
+ ("Pixel 7", "Android 14", "416dpi 416dpi 1080x2400", "arm64-v8a"),
47
+ ("Pixel 7 Pro", "Android 14", "512dpi 512dpi 1440x3120", "arm64-v8a"),
48
+ ("Pixel 8", "Android 14", "428dpi 428dpi 1080x2400", "arm64-v8a"),
49
+ ("OnePlus NE2213", "Android 14", "525dpi 525dpi 1440x3216", "arm64-v8a"),
50
+ ("OnePlus CPH2449", "Android 14", "451dpi 451dpi 1240x2772", "arm64-v8a"),
51
+ ("realme RMX3085", "Android 13", "409dpi 409dpi 1080x2400", "arm64-v8a"),
52
+ ("realme RMX3370", "Android 13", "409dpi 409dpi 1080x2400", "arm64-v8a"),
53
+ ("realme RMX3630", "Android 13", "400dpi 400dpi 1080x2412", "arm64-v8a"),
54
+ ("HUAWEI ELS-NX9", "Android 12", "441dpi 441dpi 1080x2340", "arm64-v8a"),
55
+ ("HUAWEI VOG-L29", "Android 12", "398dpi 398dpi 1080x2340", "arm64-v8a"),
56
+ ("HONOR RMO-NX1", "Android 13", "391dpi 391dpi 1080x2388", "arm64-v8a"),
57
+ ("HONOR REA-NX9", "Android 13", "435dpi 435dpi 1200x2664", "arm64-v8a"),
58
+ )
59
+ LOCALE_TIMEZONES: tuple[tuple[str, str], ...] = (
60
+ ("ru", "Europe/Moscow"),
61
+ ("ru", "Europe/Kaliningrad"),
62
+ ("ru", "Europe/Samara"),
63
+ ("ru", "Asia/Yekaterinburg"),
64
+ ("ru", "Asia/Omsk"),
65
+ ("ru", "Asia/Novosibirsk"),
66
+ ("ru", "Asia/Krasnoyarsk"),
67
+ ("ru", "Asia/Irkutsk"),
68
+ ("ru", "Asia/Yakutsk"),
69
+ ("ru", "Asia/Vladivostok"),
70
+ )
71
+ WEB_APP_VERSION = "26.5.5"
72
+ WEB_SCREEN = "1080x1920 1.0x"
73
+
74
+
75
+ class DeviceConfig(BaseModel):
76
+ mt_instance_id: str
77
+ user_agent: MobileUserAgentPayload
78
+ device_id: str = Field(default_factory=lambda: str(uuid4()))
79
+ client_session_id: int = Field(default_factory=lambda: randint(1, 70))
80
+
81
+
82
+ class ClientConfig(BaseModel):
83
+ model_config = ConfigDict(arbitrary_types_allowed=True)
84
+
85
+ phone: str | None = None
86
+ work_dir: str = "."
87
+ session_name: str = "session.db"
88
+ device: DeviceConfig
89
+ token: str | None = None
90
+
91
+ host: str = "api.oneme.ru"
92
+ port: int = 443
93
+ use_ssl: bool = True
94
+
95
+ protocol_version: int = 10
96
+ request_timeout: float = 30.0
97
+ log_level: str = "INFO"
98
+ telemetry: bool = False
99
+
100
+ store: StoreProtocol | None = None
101
+
102
+ sync: SyncOverrides = Field(default_factory=SyncOverrides)
103
+
104
+ def ensure_config(self) -> None:
105
+ if not self.phone:
106
+ raise ValueError("Phone must be provided when no saved session exists.")
107
+
108
+
109
+ class ExtraConfig(BaseModel):
110
+ """Дополнительные настройки ``Client`` и ``WebClient``.
111
+
112
+ Используйте ``ExtraConfig`` для token-логина, debug-логов, reconnect,
113
+ пользовательского device/user-agent и переопределения sync-state.
114
+
115
+ Args:
116
+ token: Готовый token для создания сессии без SMS/QR.
117
+ host: TCP host Max API.
118
+ port: TCP port Max API.
119
+ url: WebSocket URL для ``WebClient``.
120
+ use_ssl: Использовать TLS для TCP.
121
+ proxy: Proxy URL для TCP- или WebSocket-транспорта.
122
+ reconnect: Переподключаться после сетевых ошибок.
123
+ reconnect_delay: Пауза перед reconnect.
124
+ device_id: Явный device ID. Если не передан, генерируется UUID.
125
+ device_type: Тип устройства для mobile user-agent.
126
+ user_agent: Полностью заданный user-agent payload.
127
+ mt_instance_id: Instance ID устройства.
128
+ request_timeout: Timeout API-запросов в секундах.
129
+ log_level: Уровень логов ``pymax``.
130
+ telemetry: Отправлять telemetry-события Max.
131
+ sync: Переопределения sync-маркеров для login.
132
+
133
+ Example:
134
+ .. code-block:: python
135
+
136
+ from pymax import Client, ExtraConfig, SyncOverrides
137
+
138
+ client = Client(
139
+ phone="+79990000000",
140
+ extra_config=ExtraConfig(
141
+ log_level="DEBUG",
142
+ reconnect=False,
143
+ sync=SyncOverrides(chats_sync=-1),
144
+ ),
145
+ )
146
+ """
147
+
148
+ model_config = ConfigDict(arbitrary_types_allowed=True)
149
+
150
+ token: str | None = None
151
+
152
+ host: str = "api.oneme.ru"
153
+ port: int = 443
154
+ url: str = "wss://ws-api.oneme.ru/websocket"
155
+ use_ssl: bool = True
156
+ proxy: str | None = None
157
+ reconnect: bool = True
158
+ reconnect_delay: float = 1.0
159
+
160
+ device_id: str | None = None
161
+ device_type: DeviceType = DeviceType.ANDROID
162
+ user_agent: MobileUserAgentPayload | None = None
163
+ mt_instance_id: str = Field(default_factory=lambda: str(uuid4()))
164
+
165
+ request_timeout: float = 30.0
166
+ log_level: str = "INFO"
167
+ telemetry: bool = True
168
+
169
+ store: StoreProtocol | None = None
170
+
171
+ sync: SyncOverrides = Field(default_factory=SyncOverrides)
172
+
173
+ def generate_user_agent(self) -> MobileUserAgentPayload:
174
+ """Создает mobile user-agent payload для TCP-клиента.
175
+
176
+ Returns:
177
+ Случайная, но правдоподобная конфигурация Android-клиента Max.
178
+ """
179
+ app_version, build_number = choice(APP_VERSIONS)
180
+ device_name, os_version, screen, arch = choice(ANDROID_DEVICES)
181
+ locale, timezone = choice(LOCALE_TIMEZONES)
182
+
183
+ return MobileUserAgentPayload(
184
+ device_type=self.device_type,
185
+ app_version=app_version,
186
+ os_version=os_version,
187
+ timezone=timezone,
188
+ screen=screen,
189
+ push_device_type="GCM",
190
+ arch=arch,
191
+ locale=locale,
192
+ build_number=build_number,
193
+ device_name=device_name,
194
+ device_locale=locale,
195
+ )
196
+
197
+ def generate_web_user_agent(self) -> MobileUserAgentPayload:
198
+ """Создает web user-agent payload для ``WebClient``.
199
+
200
+ Returns:
201
+ Конфигурация web-клиента Max с ``DeviceType.WEB``.
202
+ """
203
+ locale, timezone = choice(LOCALE_TIMEZONES)
204
+
205
+ return MobileUserAgentPayload(
206
+ device_type=DeviceType.WEB,
207
+ app_version=WEB_APP_VERSION,
208
+ os_version="Linux",
209
+ timezone=timezone,
210
+ screen=WEB_SCREEN,
211
+ locale=locale,
212
+ device_name="Chrome",
213
+ device_locale=locale,
214
+ header_user_agent=DEFAULT_WEB_HEADER_USER_AGENT,
215
+ )
@@ -0,0 +1 @@
1
+ from .connection import ConnectionManager
@@ -0,0 +1,205 @@
1
+ import asyncio
2
+ from collections.abc import Awaitable, Callable
3
+ from contextlib import suppress
4
+
5
+ from pymax.logging import get_logger
6
+ from pymax.protocol import Command, InboundFrame, OutboundFrame
7
+ from pymax.protocol.base import BaseProtocol
8
+ from pymax.transport.base import Transport
9
+
10
+ from .pending import PendingRequests
11
+ from .readers.base import BaseReader
12
+
13
+ logger = get_logger(__name__)
14
+
15
+
16
+ class ConnectionManager:
17
+ def __init__(
18
+ self,
19
+ reader: BaseReader,
20
+ transport: Transport,
21
+ protocol: BaseProtocol,
22
+ on_event: Callable[[InboundFrame], Awaitable[None]] | None = None,
23
+ ) -> None:
24
+ self.reader = reader
25
+ self.transport = transport
26
+ self.protocol = protocol
27
+ self.on_event = on_event
28
+
29
+ self.requests = PendingRequests()
30
+
31
+ self._is_open = False
32
+ self._connection_lost = False
33
+ self._seq = -1
34
+
35
+ self._recv_task: asyncio.Task[None] | None = None
36
+ self._event_tasks: set[asyncio.Task[None]] = set()
37
+
38
+ async def open(self) -> None:
39
+ if self._is_open:
40
+ logger.debug("connection open skipped: already open")
41
+ return
42
+
43
+ logger.info("opening connection")
44
+ await self.transport.connect()
45
+ self._is_open = True
46
+ self._connection_lost = False
47
+
48
+ self._recv_task = asyncio.create_task(self._recv_loop())
49
+ logger.debug("receive loop started")
50
+
51
+ async def close(self) -> None:
52
+ if not self._is_open and not self._recv_task:
53
+ logger.debug("connection close skipped: already closed")
54
+ return
55
+
56
+ logger.info("closing connection")
57
+ if self._recv_task:
58
+ if not self._recv_task.done():
59
+ self._recv_task.cancel()
60
+ with suppress(asyncio.CancelledError, Exception):
61
+ await self._recv_task
62
+ logger.debug("receive loop stopped")
63
+ self._recv_task = None
64
+
65
+ for task in tuple(self._event_tasks):
66
+ if not task.done():
67
+ task.cancel()
68
+ for task in tuple(self._event_tasks):
69
+ with suppress(asyncio.CancelledError, Exception):
70
+ await task
71
+ self._event_tasks.clear()
72
+
73
+ self.requests.cancel_all()
74
+ await self.transport.close()
75
+ self._is_open = False
76
+ logger.info("connection closed")
77
+
78
+ async def fail(self, exc: Exception | None = None) -> None:
79
+ logger.warning("marking connection as failed")
80
+ self._connection_lost = True
81
+ self.requests.cancel_all(exc=exc)
82
+ await self.transport.close()
83
+
84
+ async def send(self, frame: OutboundFrame) -> None:
85
+ if not self._is_open:
86
+ logger.warning("send requested while connection is closed")
87
+ raise ConnectionError("Connection is not open")
88
+
89
+ data = self.protocol.encode(frame)
90
+ logger.debug(
91
+ "sending frame opcode=%s cmd=%s seq=%s bytes=%s",
92
+ frame.opcode,
93
+ frame.cmd,
94
+ frame.seq,
95
+ len(data),
96
+ )
97
+ await self.transport.send(data)
98
+
99
+ async def request(
100
+ self,
101
+ frame: OutboundFrame,
102
+ *,
103
+ timeout: float | None = None,
104
+ ) -> InboundFrame:
105
+ future = self.requests.create(frame.seq)
106
+ try:
107
+ raw = self.protocol.encode(frame)
108
+ logger.debug(
109
+ "sending request opcode=%s cmd=%s seq=%s bytes=%s timeout=%s",
110
+ frame.opcode,
111
+ frame.cmd,
112
+ frame.seq,
113
+ len(raw),
114
+ timeout,
115
+ )
116
+ await self.transport.send(raw)
117
+ return await asyncio.wait_for(future, timeout)
118
+ except Exception as e:
119
+ logger.exception(
120
+ "request failed seq=%s opcode=%s",
121
+ frame.seq,
122
+ frame.opcode,
123
+ )
124
+ self.requests.reject(frame.seq, e)
125
+ raise
126
+
127
+ async def wait_closed(self) -> None:
128
+ if not self._recv_task:
129
+ return
130
+
131
+ await self._recv_task
132
+ if self._connection_lost:
133
+ raise ConnectionError("Connection lost")
134
+
135
+ async def _recv_loop(self) -> None:
136
+ logger.debug("receive loop entered")
137
+ try:
138
+ while True:
139
+ frame = await self.reader.read()
140
+ if frame is None:
141
+ logger.warning("reader returned empty frame")
142
+ continue
143
+
144
+ logger.debug("received raw frame bytes=%s", len(frame))
145
+ model = self.protocol.decode(frame)
146
+ await self._handle_inbound(model)
147
+
148
+ except EOFError as e:
149
+ logger.warning("connection closed by server")
150
+ self.requests.cancel_all(exc=ConnectionError("Connection closed by the server"))
151
+ self._connection_lost = True
152
+ except TimeoutError as e:
153
+ logger.exception("connection timed out")
154
+ self.requests.cancel_all(exc=ConnectionError("Connection timed out"))
155
+ self._connection_lost = True
156
+ raise e
157
+ except Exception as e:
158
+ logger.exception("connection receive loop failed")
159
+ self.requests.cancel_all(exc=ConnectionError(f"Connection error: {e}"))
160
+ self._connection_lost = True
161
+ raise e
162
+
163
+ async def _handle_inbound(self, frame: InboundFrame) -> None:
164
+ logger.debug(
165
+ "inbound frame opcode=%s cmd=%s seq=%s",
166
+ frame.opcode,
167
+ frame.cmd,
168
+ frame.seq,
169
+ )
170
+
171
+ if (
172
+ frame.cmd in (Command.RESPONSE, Command.ERROR)
173
+ and frame.seq is not None
174
+ and self.requests.resolve(frame.seq, frame)
175
+ ):
176
+ logger.debug("resolved pending request seq=%s", frame.seq)
177
+
178
+ if self.on_event:
179
+ task = asyncio.create_task(self._dispatch_event(frame))
180
+ self._event_tasks.add(task)
181
+ task.add_done_callback(self._event_tasks.discard)
182
+ else:
183
+ logger.debug("inbound event dropped: no event handler")
184
+
185
+ async def _dispatch_event(self, frame: InboundFrame) -> None:
186
+ if not self.on_event:
187
+ return
188
+
189
+ try:
190
+ await self.on_event(frame)
191
+ except Exception:
192
+ logger.exception(
193
+ "inbound event handler failed opcode=%s cmd=%s seq=%s",
194
+ frame.opcode,
195
+ frame.cmd,
196
+ frame.seq,
197
+ )
198
+
199
+ def next_seq(self) -> int:
200
+ self._seq = (self._seq + 1) % 0xFFFFFFFF
201
+ return self._seq
202
+
203
+ @property
204
+ def is_open(self) -> bool:
205
+ return self._is_open
@@ -0,0 +1,46 @@
1
+ import asyncio
2
+ from asyncio import Future
3
+
4
+ from pymax.protocol import InboundFrame
5
+
6
+
7
+ class PendingRequests:
8
+ def __init__(self) -> None:
9
+ self._pending: dict[int, Future[InboundFrame]] = {}
10
+
11
+ def create(self, seq: int) -> Future[InboundFrame]:
12
+ future = asyncio.get_running_loop().create_future()
13
+ self._pending[seq] = future
14
+ return future
15
+
16
+ def resolve(self, seq: int, frame: InboundFrame) -> bool:
17
+ future = self._pending.pop(seq, None)
18
+ if future is None:
19
+ return False
20
+
21
+ if not future.done():
22
+ future.set_result(frame)
23
+ return True
24
+
25
+ def reject(self, seq: int, exc: Exception) -> bool:
26
+ future = self._pending.pop(seq, None)
27
+ if future is None:
28
+ return False
29
+
30
+ if not future.done():
31
+ future.set_exception(exc)
32
+ return True
33
+
34
+ def discard(self, seq: int) -> None:
35
+ future = self._pending.pop(seq, None)
36
+ if future and not future.done():
37
+ future.cancel()
38
+
39
+ def cancel_all(self, exc: Exception | None = None) -> None:
40
+ for future in self._pending.values():
41
+ if not future.done():
42
+ if exc:
43
+ future.set_exception(exc)
44
+ else:
45
+ future.cancel()
46
+ self._pending.clear()
@@ -0,0 +1,2 @@
1
+ from .tcp import TCPReader
2
+ from .ws import WSReader
@@ -0,0 +1,6 @@
1
+ from abc import ABC, abstractmethod
2
+
3
+
4
+ class BaseReader(ABC):
5
+ @abstractmethod
6
+ async def read(self) -> bytes | str: ...
@@ -0,0 +1,29 @@
1
+ from pymax.logging import get_logger
2
+ from pymax.protocol.tcp.framing import TcpPacketFramer
3
+ from pymax.transport.tcp import TCPTransport
4
+
5
+ from .base import BaseReader
6
+
7
+ logger = get_logger(__name__)
8
+
9
+
10
+ class TCPReader(BaseReader):
11
+ def __init__(self, transport: TCPTransport, framer: TcpPacketFramer) -> None:
12
+ super().__init__()
13
+ self.transport = transport
14
+ self.framer = framer
15
+
16
+ async def read(self) -> bytes:
17
+ header_bytes = await self.transport.recv(10)
18
+ payload_len = self.framer.unpack_header(header_bytes)
19
+ if payload_len is None:
20
+ logger.warning(
21
+ "failed to unpack tcp packet header bytes=%s",
22
+ len(header_bytes),
23
+ )
24
+ raise ValueError("Failed to unpack TCP packet header")
25
+
26
+ logger.debug("tcp packet header read payload_len=%s", payload_len)
27
+ payload_bytes = await self.transport.recv(payload_len)
28
+ logger.debug("tcp packet payload read bytes=%s", len(payload_bytes))
29
+ return header_bytes + payload_bytes
@@ -0,0 +1,14 @@
1
+ from pymax.logging import get_logger
2
+ from pymax.transport.websocket import WebSocketTransport
3
+
4
+ from .base import BaseReader
5
+
6
+ logger = get_logger(__name__)
7
+
8
+
9
+ class WSReader(BaseReader):
10
+ def __init__(self, transport: WebSocketTransport) -> None:
11
+ self.transport = transport
12
+
13
+ async def read(self) -> bytes | str:
14
+ return await self.transport.recv()
@@ -0,0 +1,10 @@
1
+ from .dispatcher import Dispatcher
2
+ from .enums import EventType
3
+ from .router import ClientRouter, Router
4
+
5
+ __all__ = (
6
+ "ClientRouter",
7
+ "Dispatcher",
8
+ "EventType",
9
+ "Router",
10
+ )