itd-sdk 2.3.0__tar.gz → 2.4.1__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.1}/PKG-INFO +5 -3
  2. {itd_sdk-2.3.0 → itd_sdk-2.4.1}/README.md +2 -2
  3. {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/__init__.py +2 -0
  4. {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/api/dwell.py +19 -19
  5. {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/api/notifications.py +1 -1
  6. itd_sdk-2.4.1/itd/api/platform.py +17 -0
  7. {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/api/posts.py +5 -0
  8. itd_sdk-2.4.1/itd/api/sessions.py +23 -0
  9. {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/base.py +94 -74
  10. itd_sdk-2.4.1/itd/clan.py +38 -0
  11. {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/client.py +75 -26
  12. {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/comment.py +2 -2
  13. {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/enums.py +17 -2
  14. {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/exceptions.py +9 -1
  15. {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/file.py +30 -20
  16. {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/hashtag.py +13 -4
  17. {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/logger.py +9 -11
  18. {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/notification.py +64 -26
  19. {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/post.py +60 -38
  20. {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/report.py +2 -4
  21. {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/request.py +3 -3
  22. itd_sdk-2.4.1/itd/session.py +91 -0
  23. {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/user.py +55 -35
  24. {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/utils.py +12 -2
  25. itd_sdk-2.4.1/itd/version.py +73 -0
  26. {itd_sdk-2.3.0 → itd_sdk-2.4.1}/pyproject.toml +4 -1
  27. itd_sdk-2.3.0/itd/clan.py +0 -17
  28. {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/_default.py +0 -0
  29. {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/api/__init__.py +0 -0
  30. {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/api/auth.py +0 -0
  31. {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/api/comments.py +0 -0
  32. {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/api/etc.py +0 -0
  33. {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/api/files.py +0 -0
  34. {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/api/hashtags.py +0 -0
  35. {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/api/pins.py +0 -0
  36. {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/api/polls.py +0 -0
  37. {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/api/reports.py +0 -0
  38. {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/api/search.py +0 -0
  39. {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/api/subscription.py +0 -0
  40. {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/api/users.py +0 -0
  41. {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/api/verification.py +0 -0
  42. {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/pin.py +0 -0
  43. {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/poll.py +0 -0
  44. {itd_sdk-2.3.0 → itd_sdk-2.4.1}/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.1
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 revoke(client: Client, id: UUID):
18
+ return client.request('delete', f'v1/auth/sessions/{id}')
19
+
20
+ @catch_errors()
21
+ @rate_limit()
22
+ def revoke_all(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,14 +12,22 @@ 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
16
- from itd.enums import All, ALL, DebugResponseMode, RateLimitMode, BATCH, Batch
15
+ from itd.exceptions import (
16
+ ITDException, ValidationError, RateLimitError, UnauthorizedError, AccessTokenExpiredError,
17
+ DEFAULT_ERRORS, AccountDeletedError
18
+ )
19
+ from itd.enums import All, ALL, DebugResponseMode, RateLimitMode, BATCH, Batch, LoadStatus
17
20
  if TYPE_CHECKING:
18
21
  from itd.client import Client
19
22
 
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)
@@ -39,8 +47,7 @@ def _field_has_default(cls: type, name: str) -> bool:
39
47
  class ITDBaseModel:
40
48
  """Базовый класс модельки"""
41
49
  _refreshable: bool = True
42
- _loaded: bool = False
43
- _loading: bool = False
50
+ load_status: LoadStatus = LoadStatus.NO
44
51
  _load_with_parent: bool = True # load parent model if model called
45
52
  _validator: Callable[[Any], type[BaseModel]] | None = None # callable (pls use lambda), becuase we havent validator at that moment (it depends on this class)
46
53
 
@@ -53,49 +60,65 @@ class ITDBaseModel:
53
60
  value._client = client
54
61
  object.__setattr__(self, name, value)
55
62
 
56
- def _post_refresh(self):
57
- pass
63
+ def _post_refresh(self): ...
58
64
 
59
65
  @property
60
66
  def client(self) -> Client:
61
67
  return self._client
62
68
 
63
- def refresh(self) -> Any:
64
- l.warning('refresh is not implemented but have called')
65
- self._loaded = True
69
+ def _refresh(self, *, client: Client) -> dict: ...
70
+
71
+ def refresh(self, *, client: Client | None = None) -> Any:
72
+ self.load_status = LoadStatus.LOADING
73
+ self._fill_from_data(self._refresh(client=client or self.client))
74
+ self.load_status = LoadStatus.FULL
75
+ return self
76
+
77
+
78
+ def _fill_from_data(self, data: dict):
79
+ assert self._validator, 'Unable to use fill_from_data without a validator'
80
+ validated = self._validator().model_validate(data) # pyright: ignore[reportCallIssue]
81
+ self._fields_from_data = validated.model_fields_set
82
+ for name, value in validated.__dict__.items():
83
+ setattr(self, name, value)
84
+
85
+ self._post_refresh()
86
+
87
+
88
+ @classmethod
89
+ def from_dict(cls, data: dict, *, client: Client | None = None):
90
+ instance = cls.__new__(cls)
91
+ ITDBaseModel.__init__(instance, client)
92
+
93
+ instance._fill_from_data(data)
94
+ instance.load_status = LoadStatus.PARTIALLY
95
+ return instance
66
96
 
67
97
 
68
98
  if not TYPE_CHECKING:
69
99
  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()
100
+ value = _getattr(self, name)
101
+ if (
102
+ name.startswith('_') or # приватный аттрибут
103
+ name in ('client', 'model_fields_set', 'load_status') or
104
+ not _getattr(self, '_refreshable') or # не рефрешабельная модель
105
+ not _getattr(self, 'client').config.auto_load or # отключено в конфиге
106
+ callable(value) or # функция
107
+ self.load_status == LoadStatus.LOADING or
108
+ (not _getattr(self, 'client').config.load_comments_from_post and _is_comments_on_post(value, self)) or
109
+ (isinstance(value, ITDBaseModel) and not value._load_with_parent)
110
+ ):
111
+ # l.debug('return %s as is', name)
112
+ return object.__getattribute__(self, name)
113
+
114
+ if isinstance(value, FieldInfo) or (self.load_status != LoadStatus.FULL and name not in self._fields_from_data):
115
+ l.info('refresh %s field=%s load_status=%s', self.__class__.__name__, name, self.load_status.value)
116
+ self.refresh()
95
117
 
96
118
  return object.__getattribute__(self, name)
97
119
 
98
120
 
121
+
99
122
  T = TypeVar('T', bound=ITDBaseModel)
100
123
 
101
124
  class ITDList[T](ITDBaseModel, list[T]):
@@ -178,7 +201,7 @@ class ITDList[T](ITDBaseModel, list[T]):
178
201
  def _get_objects(data: dict) -> list[dict]:
179
202
  return []
180
203
 
181
- def refresh(self, count: int | All | Batch | None = None, limit: int | Batch = BATCH, client: Client | None = None) -> list[T]:
204
+ def refresh(self, count: int | All | Batch | None = None, limit: int | Batch = BATCH, *, client: Client | None = None) -> list[T]:
182
205
  """Обновить список (удалить все элементы и загрузить заново)
183
206
 
184
207
  Args:
@@ -211,18 +234,33 @@ class ITDList[T](ITDBaseModel, list[T]):
211
234
  """
212
235
  return self.load(ALL, limit, client)
213
236
 
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):
237
+ @overload
238
+ def __getitem__(self, index: int) -> T: ...
239
+
240
+ @overload
241
+ def __getitem__(self, index: slice) -> list[T]: ...
242
+
243
+ def __getitem__(self, index: int | slice) -> T | list[T]: # pyright: ignore[reportIncompatibleMethodOverride]
244
+
245
+ if isinstance(index, slice):
246
+ value: int | None = index.stop
247
+ else:
248
+ value = index
249
+
250
+ if ((value is not None and value > len(self) - 1) or value is None) and self.client.config.load_on_getitem is not None:
251
+ if value:
252
+ self._min_total = value + 1
253
+
254
+ if value is None or isinstance(self.client.config.load_on_getitem, All):
218
255
  l.debug('getitem load all')
219
256
  self.load_all()
220
257
  elif isinstance(self.client.config.load_on_getitem, Batch):
221
258
  l.debug('getitem load batch')
222
259
  self.load(BATCH)
223
260
  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)
261
+ l.debug('getitem load %s', value - len(self) + self.client.config.load_on_getitem)
262
+ self.load(value - len(self) + self.client.config.load_on_getitem)
263
+
226
264
  return super().__getitem__(index)
227
265
 
228
266
  def __next__(self) -> T:
@@ -249,29 +287,6 @@ class ITDList[T](ITDBaseModel, list[T]):
249
287
  return self.load_all()
250
288
 
251
289
 
252
- def refresh_wrapper(func):
253
- """Декоратор для self.refresh"""
254
- @wraps(func)
255
- def wrapper(self, client: Client | None = None):
256
- # if self._loading:
257
- # return
258
-
259
- # self._loading = True
260
- data = func(self, client or self.client)
261
- if self._validator:
262
- validated = self._validator().model_validate(data)
263
- self._fields_from_data = validated.model_fields_set
264
- l.debug('refresh %s', self.__class__.__name__.lower())
265
- for name, value in validated.__dict__.items():
266
- setattr(self, name, value)
267
-
268
- # self._loading = False
269
- self._loaded = True
270
- self._post_refresh()
271
-
272
- return wrapper
273
-
274
-
275
290
  def _filter_bytes(args: tuple):
276
291
  filtered = []
277
292
  for arg in args:
@@ -323,16 +338,17 @@ def catch_errors(*exceptions: ITDException):
323
338
  ):
324
339
  if isinstance(exception, ValidationError):
325
340
  exception.text = json.get('error', {}).get('message', 'Failed validation')
341
+
326
342
  if isinstance(exception, RateLimitError) and isinstance(json.get('error'), dict):
327
343
  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
344
+
345
+ if isinstance(exception, (UnauthorizedError, AccessTokenExpiredError)) and client.refresh_token:
346
+ client.refresh_auth()
334
347
  return wrapper(client, *args, **kwargs)
335
348
 
349
+ if isinstance(exception, AccountDeletedError):
350
+ exception.can_restore = json.get('error', {}).get('canRestore', True)
351
+
336
352
  raise exception
337
353
 
338
354
  if client.config.debug_response == DebugResponseMode.AFTER:
@@ -369,7 +385,7 @@ def rate_limit(delay_min: float | None = None, delay_mid: float | None = None, d
369
385
  else:
370
386
  delay = client.config._rate_limit_default
371
387
 
372
- if datetime.now() - timedelta(seconds=delay) < client.last_actions.get(func.__name__, datetime(2013, 2, 16)): # my birthday actually
388
+ if datetime.now() - timedelta(seconds=delay) < client.last_actions.get(func.__name__, datetime(1990, 1, 1)):
373
389
  delay -= (datetime.now() - client.last_actions[func.__name__]).seconds
374
390
  l.debug('anti rate limit on %s; wait %ss', func.__name__, delay)
375
391
  sleep(max(delay, 0))
@@ -382,8 +398,12 @@ def rate_limit(delay_min: float | None = None, delay_mid: float | None = None, d
382
398
  try:
383
399
  return func(client, *args, **kwargs)
384
400
  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)
401
+ if getattr(e, 'retry_after', 0) > client.config.retry_max_retry_after:
402
+ l.error('too large rate limit')
403
+ raise
404
+
405
+ retry_after = getattr(e, 'retry_after', client.config.retry_delay)
406
+ l.warning('%s on %s: wait %ss', e.__class__.__name__, func.__name__, retry_after)
387
407
  sleep(retry_after)
388
408
 
389
409
  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, *, client = None):
31
+ l.warning('TopClans.refresh is deprecated and will be removed in 2.6.0. Please 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,14 @@ 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
+ post_view_increment: bool = False
79
+
80
+ post_update_stats: bool = False
81
+ post_update_stats_interval: int = 3
82
+
83
+ view_read_speed: int = 250 # in WPM # https://scholarwithin.com/average-reading-speed
84
+ view_images_speed: int = 130 # https://news.mit.edu/2014/in-the-blink-of-an-eye-0116
73
85
 
74
86
  def __post_init__(self):
75
87
  if self.rate_limit_default:
@@ -96,28 +108,21 @@ class Config:
96
108
  case _:
97
109
  self._user_agent = self.user_agent
98
110
 
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
111
 
106
112
  self._retry_exceptions = (tuple(self.retry_exceptions) if isinstance(self.retry_exceptions, list) else self.retry_exceptions) or (RateLimitError, InternalError, RequestException)
107
113
 
108
114
 
109
115
 
110
116
  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
117
  def __init__(self, refresh: str | None = None, access: str | None = None, config: Config = Config()):
118
118
  l.info('init client refresh=%s access=%s', refresh is not None, access is not None)
119
119
  self.config = config
120
120
  self.last_actions: dict[str, datetime] = {}
121
+ self.auth_level: AuthLevel = AuthLevel.NO
122
+ self.access_token: str | None = None
123
+ self.refresh_token: str | None = None
124
+ self._user: Me | None = None
125
+ self.visible_posts: list[Post] = []
121
126
 
122
127
  self.session = Session()
123
128
  adapter = HTTPAdapter(pool_connections=1, pool_maxsize=10, pool_block=False) # idk what is this, (claude added) just for better stability
@@ -143,6 +148,32 @@ class Client:
143
148
  else:
144
149
  self.dwell_tracker = None
145
150
 
151
+ if self.config.post_update_stats:
152
+ self._start_update_timer()
153
+
154
+
155
+ def _start_update_timer(self):
156
+ l.debug('start update timer')
157
+ if not self.config.post_update_stats_interval:
158
+ return
159
+
160
+ def loop():
161
+ while True:
162
+ sleep(self.config.post_update_stats_interval)
163
+ self.update_post_stats()
164
+
165
+ self._thread = Thread(target=loop)
166
+ self._thread.daemon = True
167
+ self._thread.start()
168
+
169
+ def on_exit():
170
+ l.debug('stop update timer')
171
+ if self._thread:
172
+ self._thread.join(timeout=0)
173
+
174
+ register(on_exit)
175
+
176
+
146
177
  def request(self, method: str, url: str, params: dict = {}, files: dict[str, tuple[str, BufferedReader | bytes]] = {}, level=AuthLevel.ACCESS):
147
178
  """Сделать запрос
148
179
 
@@ -160,17 +191,29 @@ class Client:
160
191
  if level >= AuthLevel.ACCESS and self.access_token is None and url != 'v1/auth/refresh':
161
192
  self.refresh_auth()
162
193
 
163
- def _fetch():
164
- return fetch(self, method, url, params, files)
194
+ return fetch(self, method, url, params, files)
165
195
 
166
- if not self.refresh_token or url == 'v1/auth/refresh':
167
- return _fetch()
196
+ def request_sse(self, url: str):
197
+ l.debug('sse %s', url)
168
198
 
169
- try:
170
- return _fetch()
171
- except (UnauthorizedError, AccessTokenExpiredError):
199
+ if self.access_token is None and url != 'v1/auth/refresh':
172
200
  self.refresh_auth()
173
- return _fetch()
201
+
202
+ return fetch_stream(self, url)
203
+
204
+
205
+ def update_post_stats(self):
206
+ if len(self.visible_posts) == 0:
207
+ return
208
+
209
+ l.debug('update post stats count=%s', len(self.visible_posts))
210
+ stats: list[dict] = get_stats(self, [post.id for post in self.visible_posts]).json().get('posts', [])
211
+ if len(stats) != len(self.visible_posts):
212
+ raise NotFoundError('Post(s)')
213
+
214
+ for post in self.visible_posts:
215
+ post._set_stats(next((stat for stat in stats if stat['id'] == str(post.id))))
216
+
174
217
 
175
218
  def refresh_auth(self) -> str:
176
219
  """Обновить access token
@@ -243,7 +286,7 @@ class Client:
243
286
  tuple[list[User], list[Hashtag]]: Результат поиска
244
287
  """
245
288
  res = search(self, query, users_limit, hashtags_limit).json()['data']
246
- return [User._from_dict(user, False, self) for user in res['users']], [Hashtag._from_dict(hashtag, self) for hashtag in res['hashtags']]
289
+ return [User._from_dict(user, client=self) for user in res['users']], [Hashtag._from_dict(hashtag, self) for hashtag in res['hashtags']]
247
290
 
248
291
  def search_users(self, query: str, limit: int = 20) -> list[User]:
249
292
  """Поиск пользователей
@@ -295,6 +338,12 @@ class Client:
295
338
  if hashtag:
296
339
  return hashtag[0]
297
340
 
341
+ @overload
342
+ def get_follow_status(self, users: list[User | UUID | str]) -> dict[UUID, bool]: ...
343
+
344
+ @overload
345
+ def get_follow_status(self, users: User | UUID | str) -> bool: ...
346
+
298
347
  def get_follow_status(self, users: list[User | UUID | str] | User | UUID | str) -> dict[UUID, bool] | bool:
299
348
  """Получить статус подписки
300
349
 
@@ -152,12 +152,12 @@ class _CommentValidate(BaseModel, Comment):
152
152
  @classmethod
153
153
  def validate_reply_to(cls, reply_to: dict | None):
154
154
  if reply_to is not None:
155
- return User._from_dict(reply_to, False)
155
+ return User._from_dict(reply_to)
156
156
 
157
157
  @field_validator('author', mode='plain')
158
158
  @classmethod
159
159
  def validate_author(cls, author: dict):
160
- return User._from_dict(author, False)
160
+ return User._from_dict(author)
161
161
 
162
162
 
163
163