maxapi-python 1.1.19__py3-none-any.whl → 1.1.21__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: maxapi-python
3
- Version: 1.1.19
3
+ Version: 1.1.21
4
4
  Summary: Python wrapper для API мессенджера Max
5
5
  Project-URL: Homepage, https://github.com/ink-developer/PyMax
6
6
  Project-URL: Repository, https://github.com/ink-developer/PyMax
@@ -79,65 +79,47 @@ uv add -U maxapi-python
79
79
 
80
80
  ```python
81
81
  import asyncio
82
+
82
83
  from pymax import MaxClient, Message
84
+ from pymax.filters import Filters
85
+
86
+ client = MaxClient(
87
+ phone="+1234567890",
88
+ work_dir="cache", # директория для сессий
89
+ )
90
+
91
+
92
+ # Обработка входящих сообщений
93
+ @client.on_message(Filters.chat(0)) # фильтр по ID чата
94
+ async def on_message(msg: Message) -> None:
95
+ print(f"[{msg.sender}] {msg.text}")
83
96
 
84
- # Инициализация клиента
85
- phone = "+1234567890"
86
- client = MaxClient(phone=phone, work_dir="cache")
97
+ await client.send_message(
98
+ chat_id=msg.chat_id,
99
+ text="Привет, я бот на PyMax!",
100
+ )
101
+
102
+ await client.add_reaction(
103
+ chat_id=msg.chat_id,
104
+ message_id=str(msg.id),
105
+ reaction="👍",
106
+ )
87
107
 
88
- # Обработчик входящих сообщений
89
- @client.on_message()
90
- async def handle_message(message: Message) -> None:
91
- print(f"{message.sender}: {message.text}")
92
108
 
93
- # Обработчик запуска клиента
94
109
  @client.on_start
95
- async def handle_start() -> None:
96
- print("Клиент запущен")
110
+ async def on_start() -> None:
111
+ print(f"Клиент запущен. Ваш ID: {client.me.id}")
97
112
 
98
- # Получение истории сообщений
113
+ # Получение истории
99
114
  history = await client.fetch_history(chat_id=0)
100
- if history:
101
- for message in history:
102
- user = await client.get_user(message.sender)
103
- if user:
104
- print(f"{user.names[0].name}: {message.text}")
105
-
106
- async def main() -> None:
107
- await client.start()
108
-
109
- # Работа с чатами
110
- for chat in client.chats:
111
- print(f"Чат: {chat.title}")
112
-
113
- # Отправка сообщения
114
- message = await client.send_message(
115
- "Привет от PyMax!",
116
- chat.id,
117
- notify=True
118
- )
119
-
120
- # Редактирование сообщения
121
- await asyncio.sleep(2)
122
- await client.edit_message(
123
- chat.id,
124
- message.id,
125
- "Привет от PyMax! (отредактировано)"
126
- )
127
-
128
- # Удаление сообщения
129
- await asyncio.sleep(2)
130
- await client.delete_message(chat.id, [message.id], for_me=False)
131
-
132
- # Работа с диалогами
133
- for dialog in client.dialogs:
134
- print(f"Диалог: {dialog.last_message.text}")
135
-
136
- # Работа с каналами
137
- for channel in client.channels:
138
- print(f"Канал: {channel.title}")
139
-
140
- await client.close()
115
+ print("Последние сообщения из чата 0:")
116
+ for m in history:
117
+ print(f"- {m.text}")
118
+
119
+
120
+ async def main():
121
+ await client.start() # подключение и авторизация
122
+
141
123
 
142
124
  if __name__ == "__main__":
143
125
  asyncio.run(main())
@@ -145,7 +127,8 @@ if __name__ == "__main__":
145
127
 
146
128
  ## Документация
147
129
 
148
- [WIP](https://ink-developer.github.io/PyMax)
130
+ [GitHub Pages](https://maxapiteam.github.io/PyMax/)
131
+ [DeepWiki](https://deepwiki.com/MaxApiTeam/PyMax)
149
132
 
150
133
  ## Лицензия
151
134
 
@@ -0,0 +1,32 @@
1
+ pymax/__init__.py,sha256=6wUKKwsyxFpWG3b7kwptOvHd-w78C-ygw42iCDBYQvc,1915
2
+ pymax/core.py,sha256=aF-nGQG92KV9ZBIXWTxl2mpyfXgmZ2OQq2-OnouB2O4,19720
3
+ pymax/crud.py,sha256=uphxDTCj1tGCrQ1lE2osLIZY7WLWbS-pkG46i2pU8Z0,3075
4
+ pymax/exceptions.py,sha256=nDUNx7bM-Yjugj-qfIllcrnwLg9JpZroYqfXapjYbMQ,3178
5
+ pymax/files.py,sha256=AvFIr34Desq2p4CNWXIngRqeyTBKMT98VmcnI-zvUU0,3462
6
+ pymax/filters.py,sha256=gSHPJ1Vi37HKPxf0jRRv9Q3iGwhiQjw1MGrCaouqHzs,4325
7
+ pymax/formatter.py,sha256=RJ_5VbY7Li8UM3xL1AvcXo8v1iYnY8GvDDkreaFqtnY,860
8
+ pymax/formatting.py,sha256=hhtmakfcQDzQRsAckPunnJOCKY2lFdLeIp8Yw53yY8s,2522
9
+ pymax/interfaces.py,sha256=2wdS5BfguU9zH3yLSGBWtSq0_SWwcSVLHSd1Z4ZIS2g,4003
10
+ pymax/models.py,sha256=PsPGbOkERxesZZltjNrmqhOfRcO44Is2ThbEToREcB8,201
11
+ pymax/navigation.py,sha256=4ia6RGY2pXMArboNhHkbWlWX7LtcYK1VGVXorPX0Pb4,5747
12
+ pymax/payloads.py,sha256=-GEJVXXlmJiFSTX4ToVNzmSZSrvSRe-BLOwYyRxGkWY,7280
13
+ pymax/types.py,sha256=_ARcVXLGHyiGAJKYPd6EU9QDKzz4VwS6kjTu3YEH_u4,35523
14
+ pymax/mixins/__init__.py,sha256=5sXJME34S1EssuDETaN4DLRH7vhMw_Q3Jmay9myAIZM,775
15
+ pymax/mixins/auth.py,sha256=zErX2vItVwHV8rAKWXX74dEEYSlECr2oYmnzEzw86eM,9661
16
+ pymax/mixins/channel.py,sha256=W52YnBay1sUYXxF9oAWsz44ZUh_s45jSvKmAyjTbULM,5357
17
+ pymax/mixins/group.py,sha256=LqI1QHmZlmtuQ0-4H1MrNeBV-O9SMDMfHT9f4B_2poE,15189
18
+ pymax/mixins/handler.py,sha256=ETnI8fA386LYJGjWtUhhWzQHREUA78di1aO1oWwtscA,12523
19
+ pymax/mixins/message.py,sha256=AznKKmTMxdzsYl8IecT43RjWpGvlQM85GzSNGFbI8BA,33279
20
+ pymax/mixins/scheduler.py,sha256=rcMfgfZnzu5V6MkcCg6uRgbi-jkc7UyqOjemulydWbc,964
21
+ pymax/mixins/self.py,sha256=Be5L64eNYylGM-NmoxFpQZv1ohsC1Dx_Cs3Om__V96s,6976
22
+ pymax/mixins/socket.py,sha256=9_TtTzB1mXH2U-odtwvOEXViUOCzPXBgTEXbMckhiq4,23122
23
+ pymax/mixins/telemetry.py,sha256=LWr68DNQkPhAjGRDYQ5lORXxC3Yw6M9E8sF0TCNISTE,3609
24
+ pymax/mixins/user.py,sha256=RSZd4t-aq8P2k3cVzNVWBkUf-_xTWILrBzwxLRgk1pw,9450
25
+ pymax/mixins/utils.py,sha256=s3FUf3i_wjn2Gbg5YY1rWZB-90ZEGrrcUuND_MqqSTE,853
26
+ pymax/mixins/websocket.py,sha256=m2swhSHIcFG6iABAik_oWxIpHfr0sxZ74I6VRU-iVO8,17809
27
+ pymax/static/constant.py,sha256=BjSb2G6CTN6Idpo5GeLa-07YodnLNycYDmI7nOk0YGs,1111
28
+ pymax/static/enum.py,sha256=Lf_qHbA2e2oK7X2uuiXInvaV1ql6hX-4wypFnokaazM,4584
29
+ maxapi_python-1.1.21.dist-info/METADATA,sha256=_VPALryggwzxWHAcYKsKSb4rP094y1PmLEFKIOFolRs,5560
30
+ maxapi_python-1.1.21.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
31
+ maxapi_python-1.1.21.dist-info/licenses/LICENSE,sha256=hOR249ItqMdcly1A0amqEWRNRTq4Gv5NJtmQ3A5qK4E,1070
32
+ maxapi_python-1.1.21.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.27.0
2
+ Generator: hatchling 1.28.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
pymax/core.py CHANGED
@@ -1,3 +1,5 @@
1
+ from __future__ import annotations
2
+
1
3
  import asyncio
2
4
  import contextlib
3
5
  import logging
@@ -7,12 +9,17 @@ import time
7
9
  import traceback
8
10
  from collections.abc import Awaitable
9
11
  from pathlib import Path
10
- from typing import TYPE_CHECKING, Any, Literal, Self
12
+ from typing import TYPE_CHECKING, Any, Literal
13
+ from uuid import UUID
11
14
 
12
- from typing_extensions import override
15
+ from typing_extensions import Self, override
13
16
 
14
17
  from .crud import Database
15
- from .exceptions import InvalidPhoneError, WebSocketNotConnectedError
18
+ from .exceptions import (
19
+ InvalidPhoneError,
20
+ SocketNotConnectedError,
21
+ WebSocketNotConnectedError,
22
+ )
16
23
  from .formatter import ColoredFormatter
17
24
  from .mixins import ApiMixin, SocketMixin, WebSocketMixin
18
25
  from .payloads import UserAgentPayload
@@ -23,12 +30,13 @@ from .static.constant import (
23
30
  )
24
31
 
25
32
  if TYPE_CHECKING:
26
- from collections.abc import Awaitable, Callable
27
- from typing import Any
33
+ from collections.abc import Callable
28
34
 
29
35
  import websockets
30
36
 
31
- from .filters import Filter
37
+ from pymax.filters import BaseFilter
38
+
39
+ from .filters import Filters
32
40
  from .types import Channel, Chat, Dialog, Me, Message, ReactionInfo, User
33
41
 
34
42
 
@@ -39,29 +47,36 @@ class MaxClient(ApiMixin, WebSocketMixin):
39
47
  """
40
48
  Основной клиент для работы с WebSocket API сервиса Max.
41
49
 
42
-
43
- Args:
44
- phone (str): Номер телефона для авторизации.
45
- uri (str, optional): URI WebSocket сервера. По умолчанию Constants.WEBSOCKET_URI.value.
46
- work_dir (str, optional): Рабочая директория для хранения базы данных. По умолчанию ".".
47
- logger (logging.Logger | None): Пользовательский логгер. Если не передан — используется
48
- логгер модуля с именем f"{__name__}.MaxClient".
49
- headers (UserAgentPayload): Заголовки для подключения к WebSocket.
50
- token (str | None, optional): Токен авторизации. Если не передан, будет выполнен
51
- процесс логина по номеру телефона.
52
- host (str, optional): Хост API сервера. По умолчанию Constants.HOST.value.
53
- port (int, optional): Порт API сервера. По умолчанию Constants.PORT.value.
54
- registration (bool, optional): Флаг регистрации нового пользователя. По умолчанию False.
55
- first_name (str, optional): Имя пользователя для регистрации. Требуется, если registration=True.
56
- last_name (str | None, optional): Фамилия пользователя для регистрации.
57
- send_fake_telemetry (bool, optional): Флаг отправки фейковой телеметрии. По умолчанию True.
58
- proxy (str | Literal[True] | None, optional): Прокси для подключения к WebSocket.
59
- (См. https://websockets.readthedocs.io/en/stable/topics/proxies.html).
60
- reconnect (bool, optional): Флаг автоматического переподключения при потере соединения. По умолчанию True.
61
-
62
-
63
- Raises:
64
- InvalidPhoneError: Если формат номера телефона неверный.
50
+ :param phone: Номер телефона для авторизации.
51
+ :type phone: str
52
+ :param uri: URI WebSocket сервера.
53
+ :type uri: str, optional
54
+ :param work_dir: Рабочая директория для хранения базы данных.
55
+ :type work_dir: str, optional
56
+ :param logger: Пользовательский логгер. Если не передан, используется логгер модуля с именем f"{__name__}.MaxClient".
57
+ :type logger: logging.Logger | None
58
+ :param headers: Заголовки для подключения к WebSocket.
59
+ :type headers: UserAgentPayload
60
+ :param token: Токен авторизации. Если не передан, будет выполнен процесс логина по номеру телефона.
61
+ :type token: str | None, optional
62
+ :param host: Хост API сервера.
63
+ :type host: str, optional
64
+ :param port: Порт API сервера.
65
+ :type port: int, optional
66
+ :param registration: Флаг регистрации нового пользователя.
67
+ :type registration: bool, optional
68
+ :param first_name: Имя пользователя для регистрации. Требуется, если registration=True.
69
+ :type first_name: str, optional
70
+ :param last_name: Фамилия пользователя для регистрации.
71
+ :type last_name: str | None, optional
72
+ :param send_fake_telemetry: Флаг отправки фейковой телеметрии.
73
+ :type send_fake_telemetry: bool, optional
74
+ :param proxy: Прокси для подключения к WebSocket (см. https://websockets.readthedocs.io/en/stable/topics/proxies.html).
75
+ :type proxy: str | Literal[True] | None, optional
76
+ :param reconnect: Флаг автоматического переподключения при потере соединения.
77
+ :type reconnect: bool, optional
78
+
79
+ :raises InvalidPhoneError: Если формат номера телефона неверный.
65
80
  """
66
81
 
67
82
  def __init__(
@@ -78,6 +93,7 @@ class MaxClient(ApiMixin, WebSocketMixin):
78
93
  registration: bool = False,
79
94
  first_name: str = "",
80
95
  last_name: str | None = None,
96
+ device_id: UUID | None = None,
81
97
  logger: logging.Logger | None = None,
82
98
  reconnect: bool = True,
83
99
  reconnect_delay: float = 1.0,
@@ -123,7 +139,9 @@ class MaxClient(ApiMixin, WebSocketMixin):
123
139
  self._circuit_breaker: bool = False
124
140
  self._last_error_time: float = 0.0
125
141
 
126
- self._device_id = self._database.get_device_id()
142
+ self._device_id = (
143
+ device_id if device_id is not None else self._database.get_device_id()
144
+ )
127
145
  self._file_upload_waiters: dict[int, asyncio.Future[dict[str, Any]]] = {}
128
146
 
129
147
  self._token = self._database.get_auth_token() or token
@@ -134,19 +152,25 @@ class MaxClient(ApiMixin, WebSocketMixin):
134
152
  self._current_screen: str = "chats_list_tab"
135
153
 
136
154
  self._on_message_handlers: list[
137
- tuple[Callable[[Message], Any], Filter | None]
155
+ tuple[Callable[[Message], Any], BaseFilter[Message] | None]
138
156
  ] = []
139
157
  self._on_message_edit_handlers: list[
140
- tuple[Callable[[Message], Any], Filter | None]
158
+ tuple[Callable[[Message], Any], BaseFilter[Message] | None]
141
159
  ] = []
142
160
  self._on_message_delete_handlers: list[
143
- tuple[Callable[[Message], Any], Filter | None]
161
+ tuple[Callable[[Message], Any], BaseFilter[Message] | None]
144
162
  ] = []
145
163
  self._on_start_handler: Callable[[], Any | Awaitable[Any]] | None = None
146
164
  self._on_reaction_change_handlers: list[
147
165
  tuple[Callable[[str, int, ReactionInfo], Any]]
148
166
  ] = []
149
167
  self._on_chat_update_handlers: list[tuple[Callable[[Chat], Any]]] = []
168
+ self._on_raw_receive_handlers: list[
169
+ Callable[[dict[str, Any]], Any | Awaitable[Any]]
170
+ ] = []
171
+ self._scheduled_tasks: list[
172
+ tuple[Callable[[], Any | Awaitable[Any]], float]
173
+ ] = []
150
174
 
151
175
  self._ssl_context = ssl.create_default_context()
152
176
  self._ssl_context.set_ciphers("DEFAULT")
@@ -176,13 +200,13 @@ class MaxClient(ApiMixin, WebSocketMixin):
176
200
  handler.setFormatter(formatter)
177
201
  self.logger.addHandler(handler)
178
202
 
179
- async def _wait_forever(self):
203
+ async def _wait_forever(self) -> None:
180
204
  try:
181
205
  await self.ws.wait_closed()
182
206
  except asyncio.CancelledError:
183
207
  self.logger.debug("wait_closed cancelled")
184
208
 
185
- async def _safe_execute(self, coro, *, context: str = "unknown"):
209
+ async def _safe_execute(self, coro, *, context: str = "unknown") -> Any:
186
210
  """
187
211
  Безопасно выполняет пользовательскую корутину.
188
212
  Логирует traceback, но не роняет event loop.
@@ -195,6 +219,11 @@ class MaxClient(ApiMixin, WebSocketMixin):
195
219
  )
196
220
 
197
221
  async def close(self) -> None:
222
+ """
223
+ Закрывает клиент и освобождает ресурсы.
224
+
225
+ :return: None
226
+ """
198
227
  try:
199
228
  self.logger.info("Closing client")
200
229
  if self._recv_task:
@@ -226,11 +255,11 @@ class MaxClient(ApiMixin, WebSocketMixin):
226
255
  except asyncio.CancelledError:
227
256
  raise
228
257
  except Exception as e:
229
- self.logger.exception(
230
- f"Unhandled exception in task {name or coro}: {e}",
231
- exc_info=e,
258
+ tb = traceback.format_exc()
259
+ self.logger.error(
260
+ f"Unhandled exception in task {name or coro}: {e}\n{tb}"
232
261
  )
233
- return None
262
+ raise
234
263
 
235
264
  task = asyncio.create_task(runner(), name=name)
236
265
  self._background_tasks.add(task)
@@ -240,22 +269,26 @@ class MaxClient(ApiMixin, WebSocketMixin):
240
269
  if sync:
241
270
  await self._sync()
242
271
 
243
- if self._on_start_handler:
244
- self.logger.debug("Calling on_start handler")
245
- result = self._on_start_handler()
246
- if asyncio.iscoroutine(result):
247
- await self._safe_execute(result, context="on_start handler")
248
-
272
+ self.logger.debug("is_connected=%s before starting ping", self.is_connected)
249
273
  ping_task = asyncio.create_task(self._send_interactive_ping())
250
274
  ping_task.add_done_callback(self._log_task_exception)
251
275
  self._background_tasks.add(ping_task)
252
276
 
277
+ start_scheduled_task = asyncio.create_task(self._start_scheduled_tasks())
278
+ start_scheduled_task.add_done_callback(self._log_task_exception)
279
+
253
280
  if self._send_fake_telemetry:
254
281
  telemetry_task = asyncio.create_task(self._start())
255
282
  telemetry_task.add_done_callback(self._log_task_exception)
256
283
  self._background_tasks.add(telemetry_task)
257
284
 
258
- async def _cleanup_client(self):
285
+ if self._on_start_handler:
286
+ self.logger.debug("Calling on_start handler")
287
+ result = self._on_start_handler()
288
+ if asyncio.iscoroutine(result):
289
+ await self._safe_execute(result, context="on_start handler")
290
+
291
+ async def _cleanup_client(self) -> None:
259
292
  for task in list(self._background_tasks):
260
293
  task.cancel()
261
294
  try:
@@ -301,17 +334,21 @@ class MaxClient(ApiMixin, WebSocketMixin):
301
334
  """
302
335
  Завершает кастомный login flow: отправляет код, сохраняет токен и запускает пост-логин задачи.
303
336
 
304
- Args:
305
- temp_token (str): Временный токен, полученный из request_code()
306
- code (str): Код, введённый пользователем
307
-
308
- Returns:
309
- str: Токен для входа
337
+ :param temp_token: Временный токен, полученный из request_code.
338
+ :type temp_token: str
339
+ :param code: Код верификации (6 цифр).
340
+ :type code: str
341
+ :param start: Флаг запуска пост-логин задач и ожидания навсегда. Если False, только сохраняет токен.
342
+ :type start: bool, optional
343
+ :return: None
344
+ :rtype: None
310
345
  """
311
346
  resp = await self._send_code(code, temp_token)
312
347
  token = resp.get("tokenAttrs", {}).get("LOGIN", {}).get("token")
348
+ if not token:
349
+ raise ValueError("Login response did not contain tokenAttrs.LOGIN.token")
313
350
  self._token = token
314
- self._database.update_auth_token(self._device_id, token)
351
+ self._database.update_auth_token(str(self._device_id), token)
315
352
  if start:
316
353
  while True:
317
354
  try:
@@ -332,6 +369,9 @@ class MaxClient(ApiMixin, WebSocketMixin):
332
369
  Запускает клиент, подключается к WebSocket, авторизует
333
370
  пользователя (если нужно) и запускает фоновый цикл.
334
371
  Теперь включает безопасный reconnect-loop, если self.reconnect=True.
372
+
373
+ :return: None
374
+ :rtype: None
335
375
  """
336
376
 
337
377
  while True:
@@ -345,7 +385,7 @@ class MaxClient(ApiMixin, WebSocketMixin):
345
385
  await self._register(self.first_name, self.last_name)
346
386
 
347
387
  if self._token and self._database.get_auth_token() is None:
348
- self._database.update_auth_token(self._device_id, self._token)
388
+ self._database.update_auth_token(str(self._device_id), self._token)
349
389
 
350
390
  if self._token is None:
351
391
  await self._login()
@@ -356,7 +396,6 @@ class MaxClient(ApiMixin, WebSocketMixin):
356
396
 
357
397
  await self._wait_forever()
358
398
  self.logger.info("WebSocket closed (wait_forever exited)")
359
-
360
399
  except Exception as e:
361
400
  self.logger.exception("Client start iteration failed")
362
401
  raise e
@@ -374,8 +413,33 @@ class MaxClient(ApiMixin, WebSocketMixin):
374
413
  await asyncio.sleep(self.reconnect_delay)
375
414
 
376
415
  async def idle(self):
416
+ """
417
+ Поддерживает клиента в «ожидающем» состоянии до закрытия клиента или иного прерывающего события.
418
+
419
+ :return: Никогда не возвращает значение; функция блокирует выполнение.
420
+ :rtype: None
421
+ """
377
422
  await asyncio.Event().wait()
378
423
 
424
+ def inspect(self) -> None:
425
+ """
426
+ Выводит в лог текущий статус клиента для отладки.
427
+ """
428
+ self.logger.info("Pymax")
429
+ self.logger.info("---------")
430
+ self.logger.info(f"Connected: {self.is_connected}")
431
+ if self.me is not None:
432
+ self.logger.info(f"Me: {self.me.names[0].first_name} ({self.me.id})")
433
+ else:
434
+ self.logger.info("Me: N/A")
435
+ self.logger.info(f"Dialogs: {len(self.dialogs)}")
436
+ self.logger.info(f"Chats: {len(self.chats)}")
437
+ self.logger.info(f"Channels: {len(self.channels)}")
438
+ self.logger.info(f"Users cached: {len(self._users)}")
439
+ self.logger.info(f"Background tasks: {len(self._background_tasks)}")
440
+ self.logger.info(f"Scheduled tasks: {len(self._scheduled_tasks)}")
441
+ self.logger.info("---------")
442
+
379
443
  async def __aenter__(self) -> Self:
380
444
  self._create_safe_task(self.start(), name="start")
381
445
  while not self.is_connected:
@@ -396,3 +460,51 @@ class SocketMaxClient(SocketMixin, MaxClient):
396
460
  self.logger.debug("Socket recv_task cancelled")
397
461
  except Exception as e:
398
462
  self.logger.exception("Socket recv_task failed: %s", e)
463
+
464
+ @override
465
+ async def _cleanup_client(self):
466
+ """
467
+ Socket-specific cleanup: cancel background tasks, set pending futures
468
+ exceptions to SocketNotConnectedError, and close socket.
469
+ """
470
+ from .exceptions import SocketNotConnectedError
471
+
472
+ for task in list(self._background_tasks):
473
+ task.cancel()
474
+ try:
475
+ await task
476
+ except asyncio.CancelledError:
477
+ pass
478
+ except Exception:
479
+ self.logger.debug(
480
+ "Background task raised during cancellation (socket)",
481
+ exc_info=True,
482
+ )
483
+ self._background_tasks.discard(task)
484
+
485
+ if self._recv_task:
486
+ self._recv_task.cancel()
487
+ with contextlib.suppress(asyncio.CancelledError):
488
+ await self._recv_task
489
+ self._recv_task = None
490
+
491
+ if self._outgoing_task:
492
+ self._outgoing_task.cancel()
493
+ with contextlib.suppress(asyncio.CancelledError):
494
+ await self._outgoing_task
495
+ self._outgoing_task = None
496
+
497
+ for fut in self._pending.values():
498
+ if not fut.done():
499
+ fut.set_exception(SocketNotConnectedError())
500
+ self._pending.clear()
501
+
502
+ if self._socket:
503
+ try:
504
+ self._socket.close()
505
+ except Exception:
506
+ self.logger.debug("Error closing socket during cleanup", exc_info=True)
507
+ self._socket = None
508
+
509
+ self.is_connected = False
510
+ self.logger.info("Client start() cleaned up (socket)")
pymax/crud.py CHANGED
@@ -31,9 +31,8 @@ class Database:
31
31
 
32
32
  def get_device_id(self) -> UUID:
33
33
  with self.get_session() as session:
34
- device_id = cast(
35
- UUID | None, session.exec(select(Auth.device_id)).first()
36
- )
34
+ device_id = session.exec(select(Auth.device_id)).first()
35
+
37
36
  if device_id is None:
38
37
  auth = Auth()
39
38
  session.add(auth)
@@ -49,11 +48,9 @@ class Database:
49
48
  session.refresh(auth)
50
49
  return auth
51
50
 
52
- def update_auth_token(self, device_id: UUID, token: str) -> None:
51
+ def update_auth_token(self, device_id: str, token: str) -> None:
53
52
  with self.get_session() as session:
54
- auth = session.exec(
55
- select(Auth).where(Auth.device_id == device_id)
56
- ).first()
53
+ auth = session.exec(select(Auth).where(Auth.device_id == device_id)).first()
57
54
  if auth:
58
55
  auth.token = token
59
56
  session.add(auth)
@@ -86,7 +83,6 @@ class Database:
86
83
  with self.get_session() as session:
87
84
  rows = session.exec(select(Auth)).all()
88
85
  if not rows:
89
- # Create default Auth with device type from enum
90
86
  auth = Auth(device_type=DeviceType.WEB.value)
91
87
  session.add(auth)
92
88
  session.commit()
pymax/files.py CHANGED
@@ -9,9 +9,7 @@ from typing_extensions import override
9
9
 
10
10
 
11
11
  class BaseFile(ABC):
12
- def __init__(
13
- self, url: str | None = None, path: str | None = None
14
- ) -> None:
12
+ def __init__(self, url: str | None = None, path: str | None = None) -> None:
15
13
  self.url = url
16
14
  self.path = path
17
15
 
@@ -47,9 +45,7 @@ class Photo(BaseFile):
47
45
  ".bmp",
48
46
  } # FIXME: костыль ✅
49
47
 
50
- def __init__(
51
- self, url: str | None = None, path: str | None = None
52
- ) -> None:
48
+ def __init__(self, url: str | None = None, path: str | None = None) -> None:
53
49
  super().__init__(url, path)
54
50
 
55
51
  def validate_photo(self) -> tuple[str, str] | None:
@@ -71,9 +67,7 @@ class Photo(BaseFile):
71
67
  mime_type = mimetypes.guess_type(self.url)[0]
72
68
 
73
69
  if not mime_type or not mime_type.startswith("image/"):
74
- raise ValueError(
75
- f"URL does not appear to be an image: {self.url}"
76
- )
70
+ raise ValueError(f"URL does not appear to be an image: {self.url}")
77
71
 
78
72
  return (extension[1:], mime_type)
79
73
  return None
@@ -84,15 +78,24 @@ class Photo(BaseFile):
84
78
 
85
79
 
86
80
  class Video(BaseFile):
81
+ def __init__(self, url: str | None = None, path: str | None = None) -> None:
82
+ self.file_name: str = ""
83
+ if path:
84
+ self.file_name = Path(path).name
85
+ elif url:
86
+ self.file_name = Path(url).name
87
+
88
+ if not self.file_name:
89
+ raise ValueError("Either url or path must be provided.")
90
+ super().__init__(url, path)
91
+
87
92
  @override
88
93
  async def read(self) -> bytes:
89
94
  return await super().read()
90
95
 
91
96
 
92
97
  class File(BaseFile):
93
- def __init__(
94
- self, url: str | None = None, path: str | None = None
95
- ) -> None:
98
+ def __init__(self, url: str | None = None, path: str | None = None) -> None:
96
99
  self.file_name: str = ""
97
100
  if path:
98
101
  self.file_name = Path(path).name