itd-sdk 2.3.0__tar.gz → 2.4.0__tar.gz

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 (44) hide show
  1. {itd_sdk-2.3.0 → itd_sdk-2.4.0}/PKG-INFO +5 -3
  2. {itd_sdk-2.3.0 → itd_sdk-2.4.0}/README.md +2 -2
  3. {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/__init__.py +2 -0
  4. {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/api/dwell.py +19 -19
  5. {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/api/notifications.py +1 -1
  6. itd_sdk-2.4.0/itd/api/platform.py +17 -0
  7. {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/api/posts.py +5 -0
  8. itd_sdk-2.4.0/itd/api/sessions.py +23 -0
  9. {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/base.py +64 -43
  10. itd_sdk-2.4.0/itd/clan.py +38 -0
  11. {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/client.py +73 -25
  12. {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/enums.py +11 -0
  13. {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/exceptions.py +9 -1
  14. {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/file.py +10 -0
  15. {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/hashtag.py +10 -0
  16. {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/logger.py +9 -11
  17. {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/notification.py +62 -24
  18. {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/post.py +39 -9
  19. {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/request.py +3 -3
  20. itd_sdk-2.4.0/itd/session.py +79 -0
  21. {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/user.py +37 -12
  22. {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/utils.py +12 -2
  23. itd_sdk-2.4.0/itd/version.py +73 -0
  24. {itd_sdk-2.3.0 → itd_sdk-2.4.0}/pyproject.toml +58 -55
  25. itd_sdk-2.3.0/itd/clan.py +0 -17
  26. {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/_default.py +0 -0
  27. {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/api/__init__.py +0 -0
  28. {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/api/auth.py +0 -0
  29. {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/api/comments.py +0 -0
  30. {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/api/etc.py +0 -0
  31. {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/api/files.py +0 -0
  32. {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/api/hashtags.py +0 -0
  33. {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/api/pins.py +0 -0
  34. {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/api/polls.py +0 -0
  35. {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/api/reports.py +0 -0
  36. {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/api/search.py +0 -0
  37. {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/api/subscription.py +0 -0
  38. {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/api/users.py +0 -0
  39. {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/api/verification.py +0 -0
  40. {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/comment.py +0 -0
  41. {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/pin.py +0 -0
  42. {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/poll.py +0 -0
  43. {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/report.py +0 -0
  44. {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/span.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: itd-sdk
3
- Version: 2.3.0
3
+ Version: 2.4.0
4
4
  Summary: ITD client for python
5
5
  Keywords: itd,itdsocial
6
6
  Author: firedotguy
@@ -14,18 +14,20 @@ Classifier: Framework :: Pydantic :: 2
14
14
  Requires-Dist: requests~=2.32.5
15
15
  Requires-Dist: pydantic>=2.12.5,<2.14.0
16
16
  Requires-Dist: sseclient-py~=1.9.0
17
+ Requires-Dist: dateparser>=1.2.2 ; extra == 'date'
17
18
  Requires-Dist: filetype>=1.2.0 ; extra == 'filetype'
18
19
  Requires-Dist: rich>=15.0.0 ; extra == 'logs'
19
20
  Requires-Python: >=3.9
20
21
  Project-URL: Homepage, https://itdsdk.qzz.io
21
22
  Project-URL: Documentation, https://itdsdk.qzz.io/docs
23
+ Provides-Extra: date
22
24
  Provides-Extra: filetype
23
25
  Provides-Extra: logs
24
26
  Description-Content-Type: text/markdown
25
27
 
26
28
  # itd-sdk
27
29
  Клиент ITD для python
28
- Документация (beta): https://firedotguy.github.io/itd-sdk
30
+ Документация (beta): https://itdsdk.qzz.io/docs
29
31
 
30
32
  ## Установка
31
33
 
@@ -123,7 +125,7 @@ fetch(c, 'метод', 'эндпоинт', {'данные': 'данные'})
123
125
  ## Прочее
124
126
  - Лицезия: [MIT](./LICENSE)
125
127
  - Автор:
126
- - ИТД: [itd_sdk](https://xn--d1ah4a.com/@itd_sdk) или [@fdg](https://xn--d1ah4a.com/@pingbot)
128
+ - ИТД: [@itd_sdk](https://xn--d1ah4a.com/@itd_sdk) или [@fdg](https://xn--d1ah4a.com/@pingbot)
127
129
  - ТГ: [@desicars](https://t.me/desicars)
128
130
 
129
131
  [![Star History Chart](https://api.star-history.com/chart?repos=itd-sdk/itd-sdk&type=date&legend=top-left)](https://www.star-history.com/?repos=itd-sdk%2Fitd-sdk&type=date&legend=top-left)
@@ -1,6 +1,6 @@
1
1
  # itd-sdk
2
2
  Клиент ITD для python
3
- Документация (beta): https://firedotguy.github.io/itd-sdk
3
+ Документация (beta): https://itdsdk.qzz.io/docs
4
4
 
5
5
  ## Установка
6
6
 
@@ -98,7 +98,7 @@ fetch(c, 'метод', 'эндпоинт', {'данные': 'данные'})
98
98
  ## Прочее
99
99
  - Лицезия: [MIT](./LICENSE)
100
100
  - Автор:
101
- - ИТД: [itd_sdk](https://xn--d1ah4a.com/@itd_sdk) или [@fdg](https://xn--d1ah4a.com/@pingbot)
101
+ - ИТД: [@itd_sdk](https://xn--d1ah4a.com/@itd_sdk) или [@fdg](https://xn--d1ah4a.com/@pingbot)
102
102
  - ТГ: [@desicars](https://t.me/desicars)
103
103
 
104
104
  [![Star History Chart](https://api.star-history.com/chart?repos=itd-sdk/itd-sdk&type=date&legend=top-left)](https://www.star-history.com/?repos=itd-sdk%2Fitd-sdk&type=date&legend=top-left)
@@ -2,6 +2,7 @@ from importlib.metadata import version
2
2
 
3
3
  __version__ = version("itd-sdk")
4
4
 
5
+ from itd.version import Changelog, Apps
5
6
  from itd.client import Client as ITDClient, Config as ITDConfig
6
7
  from itd.clan import Clan, TopClans
7
8
  from itd.file import File
@@ -9,4 +10,5 @@ from itd.hashtag import Hashtag, Hashtags
9
10
  from itd.notification import Notifications
10
11
  from itd.post import Post, Posts, UserPosts, HashtagPosts, LikedPosts
11
12
  from itd.poll import NewPoll
13
+ from itd.session import Sessions
12
14
  from itd.user import User, Me
@@ -1,19 +1,19 @@
1
- from __future__ import annotations
2
- from typing import TYPE_CHECKING
3
- from uuid import UUID
4
-
5
- from itd.base import catch_errors, rate_limit
6
- if TYPE_CHECKING:
7
- from itd.client import Client
8
-
9
-
10
- @rate_limit()
11
- @catch_errors()
12
- def send_views(client: Client, objects: list[dict], sid: UUID):
13
- return client.request('post', 'v1/i', {'e': objects, 'sid': str(sid)})
14
-
15
-
16
- @rate_limit()
17
- @catch_errors()
18
- def send_interactions(client: Client, objects: list[dict], sid: UUID):
19
- return client.request('post', 'v1/x', {'e': objects, 'sid': str(sid)})
1
+ from __future__ import annotations
2
+ from typing import TYPE_CHECKING
3
+ from uuid import UUID
4
+
5
+ from itd.base import catch_errors, rate_limit
6
+ if TYPE_CHECKING:
7
+ from itd.client import Client
8
+
9
+
10
+ @rate_limit()
11
+ @catch_errors()
12
+ def send_views(client: Client, objects: list[dict], sid: UUID):
13
+ return client.request('post', 'v1/i', {'e': objects, 'sid': str(sid)})
14
+
15
+
16
+ @rate_limit()
17
+ @catch_errors()
18
+ def send_interactions(client: Client, objects: list[dict], sid: UUID):
19
+ return client.request('post', 'v1/x', {'e': objects, 'sid': str(sid)})
@@ -36,4 +36,4 @@ def stream_notifications(client: Client):
36
36
  Returns:
37
37
  Response: Streaming response для SSE
38
38
  """
39
- return fetch_stream(client.token, 'notifications/stream', session=client.session)
39
+ return client.request_sse('notifications/stream')
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+ from typing import TYPE_CHECKING
3
+
4
+ if TYPE_CHECKING:
5
+ from itd.client import Client
6
+ from itd.enums import AuthLevel
7
+ from itd.base import catch_errors, rate_limit
8
+
9
+ @catch_errors()
10
+ @rate_limit()
11
+ def get_apps(client: Client):
12
+ return client.request('get', 'platform/version', level=AuthLevel.NO)
13
+
14
+ @catch_errors()
15
+ @rate_limit()
16
+ def get_changelog(client: Client):
17
+ return client.request('get', 'platform/changelog', level=AuthLevel.NO)
@@ -105,3 +105,8 @@ def like_post(client: Client, id: UUID):
105
105
  @catch_errors(NotFoundError('Post'))
106
106
  def unlike_post(client: Client, id: UUID):
107
107
  return client.request('delete', f'posts/{id}/like')
108
+
109
+ @rate_limit()
110
+ @catch_errors(ValidationError())
111
+ def get_stats(client: Client, ids: list[UUID]):
112
+ return client.request('post', 'posts/stats', {'ids': [str(id) for id in ids]}) # if not found, will return empty list
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+ from typing import TYPE_CHECKING
3
+ from uuid import UUID
4
+
5
+ if TYPE_CHECKING:
6
+ from itd.client import Client
7
+ from itd.exceptions import ValidationError
8
+ from itd.base import catch_errors, rate_limit
9
+
10
+ @catch_errors()
11
+ @rate_limit()
12
+ def get_sessions(client: Client):
13
+ return client.request('get', 'v1/auth/sessions')
14
+
15
+ @catch_errors()
16
+ @rate_limit()
17
+ def delete_session(client: Client, id: UUID):
18
+ return client.request('delete', f'v1/auth/sessions/{id}')
19
+
20
+ @catch_errors()
21
+ @rate_limit()
22
+ def delete_all_sessions(client: Client):
23
+ return client.request('delete', 'v1/auth/sessions')
@@ -1,5 +1,5 @@
1
1
  from __future__ import annotations
2
- from typing import Any, Callable, TYPE_CHECKING, Iterator, TypeVar
2
+ from typing import Any, Callable, TYPE_CHECKING, Iterator, TypeVar, overload
3
3
  from functools import wraps
4
4
  from time import sleep
5
5
  from datetime import datetime, timedelta
@@ -12,7 +12,10 @@ from pydantic_core import PydanticUndefinedType
12
12
 
13
13
  from itd._default import get_default_client
14
14
  from itd.logger import get_logger
15
- from itd.exceptions import ITDException, ValidationError, RateLimitError, UnauthorizedError, AccessTokenExpiredError, DEFAULT_ERRORS, InternalError
15
+ from itd.exceptions import (
16
+ ITDException, ValidationError, RateLimitError, UnauthorizedError, AccessTokenExpiredError,
17
+ DEFAULT_ERRORS, AccountDeletedError
18
+ )
16
19
  from itd.enums import All, ALL, DebugResponseMode, RateLimitMode, BATCH, Batch
17
20
  if TYPE_CHECKING:
18
21
  from itd.client import Client
@@ -20,6 +23,11 @@ if TYPE_CHECKING:
20
23
 
21
24
  l = get_logger('base')
22
25
 
26
+ def _is_comments_on_post(value: Any, self: Any) -> bool:
27
+ from itd.comment import Comments
28
+ from itd.post import Post
29
+ return isinstance(value, Comments) and isinstance(self, Post)
30
+
23
31
  def _getattr(self: object, name: str, default: Any | None = None) -> Any:
24
32
  try:
25
33
  return object.__getattribute__(self, name)
@@ -67,35 +75,28 @@ class ITDBaseModel:
67
75
 
68
76
  if not TYPE_CHECKING:
69
77
  def __getattribute__(self, name: str) -> Any:
70
- if _getattr(self, '_refreshable') and not name.startswith('_') or name in ('client', 'model_fields_set'):
71
- try:
72
- attr = _getattr(self, name)
73
- except AttributeError:
74
- attr = None
75
-
76
- if callable(attr):
77
- return object.__getattribute__(self, name)
78
-
79
- fields_from_data = _getattr(self, '_fields_from_data', ())
80
- triggers = {
81
- 'default': name not in fields_from_data and _field_has_default(type(self), name),
82
- 'none': attr is None and not _field_has_default(type(self), name),
83
- 'field-info': isinstance(attr, FieldInfo)
84
- }
85
- if (
86
- not _getattr(self, '_loaded') and
87
- any(triggers.values()) and
88
- _getattr(self, 'client').config.auto_load and
89
- not (name == 'comments' and not _getattr(self, 'client').config.load_comments_from_post) # да я хотел сделать нормально, поставить проперти на comments и тд, но это херня кака ято крч просто нахардкожу
90
- ):
91
- l.info('load %s.%s reason=%s', self.__class__.__name__.lower(), name,
92
- next((k for k, v in triggers.items() if v))
93
- )
94
- self.refresh()
78
+ value = _getattr(self, name)
79
+ if (
80
+ name.startswith('_') or # приватный аттрибут
81
+ name in ('client', 'model_fields_set') or
82
+ not _getattr(self, '_refreshable') or # не рефрешабельная модель
83
+ not _getattr(self, 'client').config.auto_load or # отключено в конфиге
84
+ callable(value) or # функция
85
+ (not _getattr(self, 'client').config.load_comments_from_post and _is_comments_on_post(value, self)) or
86
+ (isinstance(value, ITDBaseModel) and not value._load_with_parent)
87
+ ):
88
+ # l.debug('return %s as is', name)
89
+ return object.__getattribute__(self, name)
90
+
91
+ if isinstance(value, FieldInfo) or (isinstance(value, ITDBaseModel) and value._load_with_parent and not value._loaded) or (not _getattr(self, '_loaded') and value is None):
92
+ l.info('refresh %s (caused by %s)', self.__class__.__name__, name)
93
+ self.refresh()
94
+ return object.__getattribute__(self, name)
95
95
 
96
96
  return object.__getattribute__(self, name)
97
97
 
98
98
 
99
+
99
100
  T = TypeVar('T', bound=ITDBaseModel)
100
101
 
101
102
  class ITDList[T](ITDBaseModel, list[T]):
@@ -211,18 +212,33 @@ class ITDList[T](ITDBaseModel, list[T]):
211
212
  """
212
213
  return self.load(ALL, limit, client)
213
214
 
214
- def __getitem__(self, index: int) -> T: # pyright: ignore[reportIncompatibleMethodOverride]
215
- if index > len(self) - 1 and self.client.config.load_on_getitem is not None:
216
- self._min_total = index + 1
217
- if isinstance(self.client.config.load_on_getitem, All):
215
+ @overload
216
+ def __getitem__(self, index: int) -> T: ...
217
+
218
+ @overload
219
+ def __getitem__(self, index: slice) -> list[T]: ...
220
+
221
+ def __getitem__(self, index: int | slice) -> T | list[T]: # pyright: ignore[reportIncompatibleMethodOverride]
222
+
223
+ if isinstance(index, slice):
224
+ value: int | None = index.stop
225
+ else:
226
+ value = index
227
+
228
+ if ((value is not None and value > len(self) - 1) or value is None) and self.client.config.load_on_getitem is not None:
229
+ if value:
230
+ self._min_total = value + 1
231
+
232
+ if value is None or isinstance(self.client.config.load_on_getitem, All):
218
233
  l.debug('getitem load all')
219
234
  self.load_all()
220
235
  elif isinstance(self.client.config.load_on_getitem, Batch):
221
236
  l.debug('getitem load batch')
222
237
  self.load(BATCH)
223
238
  else:
224
- l.debug('getitem load %s', index - len(self) + self.client.config.load_on_getitem)
225
- self.load(index - len(self) + self.client.config.load_on_getitem)
239
+ l.debug('getitem load %s', value - len(self) + self.client.config.load_on_getitem)
240
+ self.load(value - len(self) + self.client.config.load_on_getitem)
241
+
226
242
  return super().__getitem__(index)
227
243
 
228
244
  def __next__(self) -> T:
@@ -261,7 +277,7 @@ def refresh_wrapper(func):
261
277
  if self._validator:
262
278
  validated = self._validator().model_validate(data)
263
279
  self._fields_from_data = validated.model_fields_set
264
- l.debug('refresh %s', self.__class__.__name__.lower())
280
+ l.debug('fill %s', self.__class__.__name__)
265
281
  for name, value in validated.__dict__.items():
266
282
  setattr(self, name, value)
267
283
 
@@ -323,16 +339,17 @@ def catch_errors(*exceptions: ITDException):
323
339
  ):
324
340
  if isinstance(exception, ValidationError):
325
341
  exception.text = json.get('error', {}).get('message', 'Failed validation')
342
+
326
343
  if isinstance(exception, RateLimitError) and isinstance(json.get('error'), dict):
327
344
  exception.retry_after = json.get('error', {}).get('retryAfter', 0)
328
- if isinstance(exception, (UnauthorizedError, AccessTokenExpiredError)) and client.refresh_token and not getattr(client, '_refreshing', False):
329
- client._refreshing = True
330
- try:
331
- client.refresh_auth()
332
- finally:
333
- client._refreshing = False
345
+
346
+ if isinstance(exception, (UnauthorizedError, AccessTokenExpiredError)) and client.refresh_token:
347
+ client.refresh_auth()
334
348
  return wrapper(client, *args, **kwargs)
335
349
 
350
+ if isinstance(exception, AccountDeletedError):
351
+ exception.can_restore = json.get('error', {}).get('canRestore', True)
352
+
336
353
  raise exception
337
354
 
338
355
  if client.config.debug_response == DebugResponseMode.AFTER:
@@ -369,7 +386,7 @@ def rate_limit(delay_min: float | None = None, delay_mid: float | None = None, d
369
386
  else:
370
387
  delay = client.config._rate_limit_default
371
388
 
372
- if datetime.now() - timedelta(seconds=delay) < client.last_actions.get(func.__name__, datetime(2013, 2, 16)): # my birthday actually
389
+ if datetime.now() - timedelta(seconds=delay) < client.last_actions.get(func.__name__, datetime(1990, 1, 1)):
373
390
  delay -= (datetime.now() - client.last_actions[func.__name__]).seconds
374
391
  l.debug('anti rate limit on %s; wait %ss', func.__name__, delay)
375
392
  sleep(max(delay, 0))
@@ -382,8 +399,12 @@ def rate_limit(delay_min: float | None = None, delay_mid: float | None = None, d
382
399
  try:
383
400
  return func(client, *args, **kwargs)
384
401
  except (client.config._retry_exceptions) as e:
385
- retry_after = getattr(e, 'retry_after', client.config.rate_limit_wait) or client.config.retry_delay
386
- l.info('%s on %s: wait %ss', e.__class__.__name__, func.__name__, retry_after)
402
+ if getattr(e, 'retry_after', 0) > client.config.retry_max_retry_after:
403
+ l.error('too large rate limit')
404
+ raise
405
+
406
+ retry_after = getattr(e, 'retry_after', client.config.retry_delay)
407
+ l.warning('%s on %s: wait %ss', e.__class__.__name__, func.__name__, retry_after)
387
408
  sleep(retry_after)
388
409
 
389
410
  return wrapper
@@ -0,0 +1,38 @@
1
+ from pydantic import BaseModel, Field
2
+
3
+ from itd.base import ITDBaseModel
4
+ from itd.logger import get_logger
5
+ from itd.api.etc import get_top_clans
6
+
7
+
8
+ l = get_logger('clan')
9
+
10
+ class Clan(BaseModel):
11
+ avatar: str
12
+ members_count: int = Field(0, alias='memberCount')
13
+
14
+ def __str__(self) -> str:
15
+ return self.avatar
16
+
17
+
18
+ class TopClans(ITDBaseModel, list[Clan]):
19
+ _refreshable = False
20
+
21
+ def __init__(self) -> None:
22
+ super().__init__()
23
+ self.load()
24
+
25
+ def load(self):
26
+ self.clear()
27
+ self.extend([Clan.model_validate(clan) for clan in get_top_clans(self.client).json()['clans']])
28
+ return self
29
+
30
+ def refresh(self):
31
+ l.warning('TopClans.refresh is deprecated and will be removed in 2.6.0. Pleas use TopClans.load')
32
+ self.load()
33
+
34
+ @classmethod
35
+ def empty(cls):
36
+ instance = cls.__new__(cls)
37
+ super(TopClans, instance).__init__()
38
+ return instance
@@ -2,6 +2,10 @@ from uuid import UUID
2
2
  from _io import BufferedReader
3
3
  from datetime import datetime
4
4
  from dataclasses import dataclass, field
5
+ from atexit import register
6
+ from time import sleep
7
+ from threading import Thread
8
+ from typing import overload
5
9
 
6
10
  from requests import Session
7
11
  from requests.utils import default_user_agent
@@ -9,14 +13,15 @@ from requests.adapters import HTTPAdapter
9
13
  from requests.exceptions import RequestException
10
14
 
11
15
  from itd._default import _default_client, set_default_client
12
- from itd.exceptions import UnauthorizedError, InsufficientAuthLevelError, AccessTokenExpiredError, RateLimitError, InternalError
16
+ from itd.exceptions import InsufficientAuthLevelError, RateLimitError, InternalError, NotFoundError
13
17
  from itd.hashtag import Hashtag
14
- from itd.request import fetch, decode_jwt_payload
18
+ from itd.request import fetch, decode_jwt_payload, fetch_stream
15
19
  from itd.enums import RateLimitMode, All, DebugResponseMode, ParseMode, Batch, BATCH, UserAgent, AuthLevel
16
20
  from itd.user import Me, User
17
- from itd.post import DwellTracker
21
+ from itd.post import DwellTracker, Post
18
22
  from itd.api.auth import refresh_token, change_password, logout
19
23
  from itd.api.search import search
24
+ from itd.api.posts import get_stats
20
25
  from itd.api.users import get_follow_status
21
26
  from itd.utils import to_uuid, get_sdk_user_agent
22
27
  from itd.logger import get_logger
@@ -57,12 +62,11 @@ class Config:
57
62
 
58
63
  parse_mode: ParseMode = ParseMode.NO
59
64
 
60
- rate_limit_wait: int | None = None # DEPRECATED
61
- retry_on_rate_limits: bool | None = None # DEPRECATED
62
65
  retry_enabled: bool = True
63
66
  retry_delay: float = 10 # delay before next attempt (after rate limit error) if retry_after is not provided in request
64
67
  retry_max_retries: int | None = None # none for no limit
65
68
  retry_exceptions: tuple[type[Exception]] | list[type[Exception]] | None = None
69
+ retry_max_retry_after: int = 500
66
70
 
67
71
  bypass_auth_level: bool = False
68
72
 
@@ -70,6 +74,13 @@ class Config:
70
74
  dwell_max_buffer: int = 20
71
75
  dwell_send_interval: float = 2
72
76
  dwell_save_on_quit: bool = True
77
+ dwell_wait_durations: bool = True
78
+
79
+ post_update_stats: bool = False
80
+ post_update_stats_interval: int = 3
81
+
82
+ view_read_speed: int = 250 # in WPM # https://scholarwithin.com/average-reading-speed
83
+ view_images_speed: int = 130 # https://news.mit.edu/2014/in-the-blink-of-an-eye-0116
73
84
 
74
85
  def __post_init__(self):
75
86
  if self.rate_limit_default:
@@ -96,28 +107,21 @@ class Config:
96
107
  case _:
97
108
  self._user_agent = self.user_agent
98
109
 
99
- if self.rate_limit_wait is not None:
100
- l.warning('config.rate_limit_wait is deprecated and will be removed in 2.4.0. Please use config.retry_delay')
101
- self.retry_delay = self.rate_limit_wait
102
- if self.retry_on_rate_limits is not None:
103
- l.warning('config.retry_on_rate_limits is deprecated and will be removed in 2.4.0. Please use config.retry_enabled')
104
- self.retry_enabled = self.retry_on_rate_limits
105
110
 
106
111
  self._retry_exceptions = (tuple(self.retry_exceptions) if isinstance(self.retry_exceptions, list) else self.retry_exceptions) or (RateLimitError, InternalError, RequestException)
107
112
 
108
113
 
109
114
 
110
115
  class Client:
111
- auth_level: AuthLevel = AuthLevel.NO
112
- access_token: str | None = None
113
- refresh_token: str | None = None
114
- _user = None
115
- _refreshing: bool = False
116
-
117
116
  def __init__(self, refresh: str | None = None, access: str | None = None, config: Config = Config()):
118
117
  l.info('init client refresh=%s access=%s', refresh is not None, access is not None)
119
118
  self.config = config
120
119
  self.last_actions: dict[str, datetime] = {}
120
+ self.auth_level: AuthLevel = AuthLevel.NO
121
+ self.access_token: str | None = None
122
+ self.refresh_token: str | None = None
123
+ self._user: Me | None = None
124
+ self.visible_posts: list[Post] = []
121
125
 
122
126
  self.session = Session()
123
127
  adapter = HTTPAdapter(pool_connections=1, pool_maxsize=10, pool_block=False) # idk what is this, (claude added) just for better stability
@@ -143,6 +147,32 @@ class Client:
143
147
  else:
144
148
  self.dwell_tracker = None
145
149
 
150
+ if self.config.post_update_stats:
151
+ self._start_update_timer()
152
+
153
+
154
+ def _start_update_timer(self):
155
+ l.debug('start update timer')
156
+ if not self.config.post_update_stats_interval:
157
+ return
158
+
159
+ def loop():
160
+ while True:
161
+ sleep(self.config.post_update_stats_interval)
162
+ self.update_post_stats()
163
+
164
+ self._thread = Thread(target=loop)
165
+ self._thread.daemon = True
166
+ self._thread.start()
167
+
168
+ def on_exit():
169
+ l.debug('stop update timer')
170
+ if self._thread:
171
+ self._thread.join(timeout=0)
172
+
173
+ register(on_exit)
174
+
175
+
146
176
  def request(self, method: str, url: str, params: dict = {}, files: dict[str, tuple[str, BufferedReader | bytes]] = {}, level=AuthLevel.ACCESS):
147
177
  """Сделать запрос
148
178
 
@@ -160,17 +190,29 @@ class Client:
160
190
  if level >= AuthLevel.ACCESS and self.access_token is None and url != 'v1/auth/refresh':
161
191
  self.refresh_auth()
162
192
 
163
- def _fetch():
164
- return fetch(self, method, url, params, files)
193
+ return fetch(self, method, url, params, files)
165
194
 
166
- if not self.refresh_token or url == 'v1/auth/refresh':
167
- return _fetch()
195
+ def request_sse(self, url: str):
196
+ l.debug('sse %s', url)
168
197
 
169
- try:
170
- return _fetch()
171
- except (UnauthorizedError, AccessTokenExpiredError):
198
+ if self.access_token is None and url != 'v1/auth/refresh':
172
199
  self.refresh_auth()
173
- return _fetch()
200
+
201
+ return fetch_stream(self, url)
202
+
203
+
204
+ def update_post_stats(self):
205
+ if len(self.visible_posts) == 0:
206
+ return
207
+
208
+ l.debug('update post stats count=%s', len(self.visible_posts))
209
+ stats: list[dict] = get_stats(self, [post.id for post in self.visible_posts]).json().get('posts', [])
210
+ if len(stats) != len(self.visible_posts):
211
+ raise NotFoundError('Post(s)')
212
+
213
+ for post in self.visible_posts:
214
+ post._set_stats(next((stat for stat in stats if stat['id'] == str(post.id))))
215
+
174
216
 
175
217
  def refresh_auth(self) -> str:
176
218
  """Обновить access token
@@ -295,6 +337,12 @@ class Client:
295
337
  if hashtag:
296
338
  return hashtag[0]
297
339
 
340
+ @overload
341
+ def get_follow_status(self, users: list[User | UUID | str]) -> dict[UUID, bool]: ...
342
+
343
+ @overload
344
+ def get_follow_status(self, users: User | UUID | str) -> bool: ...
345
+
298
346
  def get_follow_status(self, users: list[User | UUID | str] | User | UUID | str) -> dict[UUID, bool] | bool:
299
347
  """Получить статус подписки
300
348
 
@@ -115,6 +115,17 @@ class CommentSorting(Enum): # actually it is not working (stupid itd api)
115
115
  NEW = 'new'
116
116
  OLD = 'old'
117
117
 
118
+ class DeviceType(Enum):
119
+ DESKTOP = 'desktop'
120
+ MOBILE = 'mobile'
121
+
122
+ # class DeviceOS(Enum):
123
+ # WINDOWS = 'Windows'
124
+ # MACOS = 'MacOS'
125
+ # LINUX = 'Linux'
126
+ # ANDROID = 'Android'
127
+ # IOS = 'iOS'
128
+
118
129
  class AccessType(Enum):
119
130
  """Типы разрешений для видимости лайков и записей на стене"""
120
131
  NOBODY = 'nobody' # никто
@@ -1,4 +1,5 @@
1
1
  from typing import Callable
2
+ from datetime import datetime
2
3
 
3
4
  from requests import Response
4
5
 
@@ -275,8 +276,15 @@ class ProfileRequiredError(ITDException):
275
276
  text = 'No profile. Please create your profile first'
276
277
 
277
278
 
279
+ class AccountDeletedError(ITDException):
280
+ code = 'ACCOUNT_DELETED'
281
+ text = 'Account has been deleted'
282
+ can_restore: bool = True
283
+ restore_deadline: datetime | None = None
284
+
285
+
278
286
  DEFAULT_ERRORS = (
279
287
  RateLimitError(), InvalidAccessTokenError(), UnauthorizedError(), AccessTokenExpiredError(),
280
288
  AccountBannedError(), AccountTemporarilyBannedError(), InternalError(),
281
- ProfileRequiredError(), RefreshTokenMissingError()
289
+ ProfileRequiredError(), RefreshTokenMissingError(), AccountDeletedError()
282
290
  )
@@ -120,6 +120,16 @@ class PostAttach(ITDBaseModel):
120
120
  assert self.type == AttachType.VIDEO, 'Recording video progress allowed only for videos'
121
121
  c.dwell_tracker.record_video_progress(self._post.vs, self._post.source, self.id, played or duration, duration)
122
122
 
123
+ @property
124
+ def extension(self):
125
+ match self.type:
126
+ case AttachType.IMAGE:
127
+ return 'jpg'
128
+ case AttachType.VIDEO:
129
+ return 'mp4'
130
+ case AttachType.AUDIO:
131
+ return 'mp3'
132
+
123
133
 
124
134
  class _PostAttachValidate(BaseModel, PostAttach):
125
135
  pass
@@ -58,7 +58,17 @@ class _HashtagValidate(BaseModel, Hashtag):
58
58
  class Hashtags(ITDBaseModel, list[Hashtag]):
59
59
  _refreshable = False
60
60
 
61
+ def __init__(self, client: Client | None = None):
62
+ super().__init__(client)
63
+ self.load()
64
+
61
65
  def load(self, count: int = 10):
62
66
  self.clear()
63
67
  self.extend([Hashtag._from_dict(hashtag) for hashtag in get_hashtags(self.client, count).json()['data']['hashtags']])
64
68
  return self
69
+
70
+ @classmethod
71
+ def empty(cls):
72
+ instance = cls.__new__(cls)
73
+ super(Hashtags, instance).__init__()
74
+ return instance