itd-sdk 2.2.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 (45) hide show
  1. {itd_sdk-2.2.0 → itd_sdk-2.4.0}/PKG-INFO +5 -4
  2. {itd_sdk-2.2.0 → itd_sdk-2.4.0}/README.md +2 -2
  3. {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/__init__.py +2 -0
  4. {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/api/auth.py +13 -1
  5. itd_sdk-2.4.0/itd/api/dwell.py +19 -0
  6. {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/api/notifications.py +1 -1
  7. itd_sdk-2.4.0/itd/api/platform.py +17 -0
  8. {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/api/posts.py +5 -0
  9. itd_sdk-2.4.0/itd/api/sessions.py +23 -0
  10. {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/base.py +70 -43
  11. itd_sdk-2.4.0/itd/clan.py +38 -0
  12. {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/client.py +95 -24
  13. {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/comment.py +9 -1
  14. {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/enums.py +34 -1
  15. {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/exceptions.py +34 -3
  16. itd_sdk-2.4.0/itd/file.py +170 -0
  17. {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/hashtag.py +10 -0
  18. {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/logger.py +9 -11
  19. {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/notification.py +62 -24
  20. {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/post.py +267 -38
  21. {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/request.py +3 -3
  22. itd_sdk-2.4.0/itd/session.py +79 -0
  23. {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/user.py +37 -12
  24. {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/utils.py +12 -2
  25. itd_sdk-2.4.0/itd/version.py +73 -0
  26. {itd_sdk-2.2.0 → itd_sdk-2.4.0}/pyproject.toml +58 -56
  27. itd_sdk-2.2.0/itd/clan.py +0 -17
  28. itd_sdk-2.2.0/itd/file.py +0 -94
  29. {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/_default.py +0 -0
  30. {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/api/__init__.py +0 -0
  31. {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/api/comments.py +0 -0
  32. {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/api/etc.py +0 -0
  33. {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/api/files.py +0 -0
  34. {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/api/hashtags.py +0 -0
  35. {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/api/pins.py +0 -0
  36. {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/api/polls.py +0 -0
  37. {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/api/reports.py +0 -0
  38. {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/api/search.py +0 -0
  39. {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/api/subscription.py +0 -0
  40. {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/api/users.py +0 -0
  41. {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/api/verification.py +0 -0
  42. {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/pin.py +0 -0
  43. {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/poll.py +0 -0
  44. {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/report.py +0 -0
  45. {itd_sdk-2.2.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.2.0
3
+ Version: 2.4.0
4
4
  Summary: ITD client for python
5
5
  Keywords: itd,itdsocial
6
6
  Author: firedotguy
@@ -11,22 +11,23 @@ Classifier: Natural Language :: Russian
11
11
  Classifier: Programming Language :: Python
12
12
  Classifier: Programming Language :: Python :: 3
13
13
  Classifier: Framework :: Pydantic :: 2
14
- Classifier: License :: OSI Approved :: MIT License
15
14
  Requires-Dist: requests~=2.32.5
16
15
  Requires-Dist: pydantic>=2.12.5,<2.14.0
17
16
  Requires-Dist: sseclient-py~=1.9.0
17
+ Requires-Dist: dateparser>=1.2.2 ; extra == 'date'
18
18
  Requires-Dist: filetype>=1.2.0 ; extra == 'filetype'
19
19
  Requires-Dist: rich>=15.0.0 ; extra == 'logs'
20
20
  Requires-Python: >=3.9
21
21
  Project-URL: Homepage, https://itdsdk.qzz.io
22
22
  Project-URL: Documentation, https://itdsdk.qzz.io/docs
23
+ Provides-Extra: date
23
24
  Provides-Extra: filetype
24
25
  Provides-Extra: logs
25
26
  Description-Content-Type: text/markdown
26
27
 
27
28
  # itd-sdk
28
29
  Клиент ITD для python
29
- Документация (beta): https://firedotguy.github.io/itd-sdk
30
+ Документация (beta): https://itdsdk.qzz.io/docs
30
31
 
31
32
  ## Установка
32
33
 
@@ -124,7 +125,7 @@ fetch(c, 'метод', 'эндпоинт', {'данные': 'данные'})
124
125
  ## Прочее
125
126
  - Лицезия: [MIT](./LICENSE)
126
127
  - Автор:
127
- - ИТД: [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)
128
129
  - ТГ: [@desicars](https://t.me/desicars)
129
130
 
130
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
@@ -5,7 +5,11 @@ from requests import Response
5
5
 
6
6
  from itd.base import catch_errors, rate_limit
7
7
  from itd.enums import AuthLevel
8
- from itd.exceptions import InvalidPasswordError, SamePasswordError, InvalidOldPasswordError, SessionNotFoundError, SessionExpiredError, SessionRevokedError
8
+ from itd.exceptions import (
9
+ InvalidPasswordError, SamePasswordError, InvalidOldPasswordError, SessionNotFoundError,
10
+ SessionExpiredError, SessionRevokedError, InvalidCredentials, CaptchaFailedError,
11
+ EmailDomainNotAllowed
12
+ )
9
13
  if TYPE_CHECKING:
10
14
  from itd.client import Client
11
15
 
@@ -14,10 +18,18 @@ if TYPE_CHECKING:
14
18
  def refresh_token(client: Client) -> Response:
15
19
  return client.request('post', 'v1/auth/refresh', level=AuthLevel.REFRESH)
16
20
 
21
+ @rate_limit()
17
22
  @catch_errors(InvalidPasswordError(), SamePasswordError(), InvalidOldPasswordError())
18
23
  def change_password(client: Client, old: str, new: str) -> Response:
19
24
  return client.request('post', 'v1/auth/change-password', {'newPassword': new, 'oldPassword': old})
20
25
 
26
+ @rate_limit()
21
27
  @catch_errors()
22
28
  def logout(client: Client) -> Response:
23
29
  return client.request('post', 'v1/auth/logout', level=AuthLevel.REFRESH)
30
+
31
+
32
+ @rate_limit()
33
+ @catch_errors(InvalidCredentials(), CaptchaFailedError(), EmailDomainNotAllowed())
34
+ def sign_in(client: Client, email: str, password: str, turnstile: str):
35
+ return client.request('post', 'v1/auth/sign-in', {'email': email, 'password': password, 'turnstileToken': turnstile}, level=AuthLevel.NO)
@@ -0,0 +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)})
@@ -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)
@@ -53,6 +61,9 @@ class ITDBaseModel:
53
61
  value._client = client
54
62
  object.__setattr__(self, name, value)
55
63
 
64
+ def _post_refresh(self):
65
+ pass
66
+
56
67
  @property
57
68
  def client(self) -> Client:
58
69
  return self._client
@@ -64,35 +75,28 @@ class ITDBaseModel:
64
75
 
65
76
  if not TYPE_CHECKING:
66
77
  def __getattribute__(self, name: str) -> Any:
67
- if _getattr(self, '_refreshable') and not name.startswith('_') or name in ('client', 'model_fields_set'):
68
- try:
69
- attr = _getattr(self, name)
70
- except AttributeError:
71
- attr = None
72
-
73
- if callable(attr):
74
- return object.__getattribute__(self, name)
75
-
76
- fields_from_data = _getattr(self, '_fields_from_data', ())
77
- triggers = {
78
- 'default': name not in fields_from_data and _field_has_default(type(self), name),
79
- 'none': attr is None and not _field_has_default(type(self), name),
80
- 'field-info': isinstance(attr, FieldInfo)
81
- }
82
- if (
83
- not _getattr(self, '_loaded') and
84
- any(triggers.values()) and
85
- _getattr(self, 'client').config.auto_load and
86
- not (name == 'comments' and not _getattr(self, 'client').config.load_comments_from_post) # да я хотел сделать нормально, поставить проперти на comments и тд, но это херня кака ято крч просто нахардкожу
87
- ):
88
- l.info('load %s.%s reason=%s', self.__class__.__name__.lower(), name,
89
- next((k for k, v in triggers.items() if v))
90
- )
91
- 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)
92
95
 
93
96
  return object.__getattribute__(self, name)
94
97
 
95
98
 
99
+
96
100
  T = TypeVar('T', bound=ITDBaseModel)
97
101
 
98
102
  class ITDList[T](ITDBaseModel, list[T]):
@@ -208,18 +212,33 @@ class ITDList[T](ITDBaseModel, list[T]):
208
212
  """
209
213
  return self.load(ALL, limit, client)
210
214
 
211
- def __getitem__(self, index: int) -> T: # pyright: ignore[reportIncompatibleMethodOverride]
212
- if index > len(self) - 1 and self.client.config.load_on_getitem is not None:
213
- self._min_total = index + 1
214
- 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):
215
233
  l.debug('getitem load all')
216
234
  self.load_all()
217
235
  elif isinstance(self.client.config.load_on_getitem, Batch):
218
236
  l.debug('getitem load batch')
219
237
  self.load(BATCH)
220
238
  else:
221
- l.debug('getitem load %s', index - len(self) + self.client.config.load_on_getitem)
222
- 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
+
223
242
  return super().__getitem__(index)
224
243
 
225
244
  def __next__(self) -> T:
@@ -258,12 +277,13 @@ def refresh_wrapper(func):
258
277
  if self._validator:
259
278
  validated = self._validator().model_validate(data)
260
279
  self._fields_from_data = validated.model_fields_set
261
- l.debug('refresh %s', self.__class__.__name__.lower())
280
+ l.debug('fill %s', self.__class__.__name__)
262
281
  for name, value in validated.__dict__.items():
263
282
  setattr(self, name, value)
264
283
 
265
284
  # self._loading = False
266
285
  self._loaded = True
286
+ self._post_refresh()
267
287
 
268
288
  return wrapper
269
289
 
@@ -292,6 +312,8 @@ def catch_errors(*exceptions: ITDException):
292
312
 
293
313
  assert isinstance(res, Response)
294
314
  if res.status_code == 204:
315
+ if client.config.debug_response != DebugResponseMode.NO:
316
+ l.debug('no response')
295
317
  return res
296
318
 
297
319
  if client.config.debug_response == DebugResponseMode.BEFORE:
@@ -317,16 +339,17 @@ def catch_errors(*exceptions: ITDException):
317
339
  ):
318
340
  if isinstance(exception, ValidationError):
319
341
  exception.text = json.get('error', {}).get('message', 'Failed validation')
342
+
320
343
  if isinstance(exception, RateLimitError) and isinstance(json.get('error'), dict):
321
344
  exception.retry_after = json.get('error', {}).get('retryAfter', 0)
322
- if isinstance(exception, (UnauthorizedError, AccessTokenExpiredError)) and client.refresh_token and not getattr(client, '_refreshing', False):
323
- client._refreshing = True
324
- try:
325
- client.refresh_auth()
326
- finally:
327
- client._refreshing = False
345
+
346
+ if isinstance(exception, (UnauthorizedError, AccessTokenExpiredError)) and client.refresh_token:
347
+ client.refresh_auth()
328
348
  return wrapper(client, *args, **kwargs)
329
349
 
350
+ if isinstance(exception, AccountDeletedError):
351
+ exception.can_restore = json.get('error', {}).get('canRestore', True)
352
+
330
353
  raise exception
331
354
 
332
355
  if client.config.debug_response == DebugResponseMode.AFTER:
@@ -363,7 +386,7 @@ def rate_limit(delay_min: float | None = None, delay_mid: float | None = None, d
363
386
  else:
364
387
  delay = client.config._rate_limit_default
365
388
 
366
- 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)):
367
390
  delay -= (datetime.now() - client.last_actions[func.__name__]).seconds
368
391
  l.debug('anti rate limit on %s; wait %ss', func.__name__, delay)
369
392
  sleep(max(delay, 0))
@@ -376,8 +399,12 @@ def rate_limit(delay_min: float | None = None, delay_mid: float | None = None, d
376
399
  try:
377
400
  return func(client, *args, **kwargs)
378
401
  except (client.config._retry_exceptions) as e:
379
- retry_after = getattr(e, 'retry_after', client.config.rate_limit_wait) or client.config.retry_delay
380
- 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)
381
408
  sleep(retry_after)
382
409
 
383
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,13 +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
21
+ from itd.post import DwellTracker, Post
17
22
  from itd.api.auth import refresh_token, change_password, logout
18
23
  from itd.api.search import search
24
+ from itd.api.posts import get_stats
19
25
  from itd.api.users import get_follow_status
20
26
  from itd.utils import to_uuid, get_sdk_user_agent
21
27
  from itd.logger import get_logger
@@ -29,31 +35,53 @@ class Config:
29
35
  rate_limit: RateLimitMode = RateLimitMode.MID
30
36
  rate_limit_default: int | None = None # overrides ratelimit mode # rate limit for standard actions
31
37
  rate_limit_actions: dict[str, float | int] = field(default_factory=lambda: {}) # overrides ratelimit mode # custom rate limits for specific actions (eg. {'add_comment': 10})
38
+
32
39
  # is_logging_enabled: bool = True # TODO
33
40
  # logging_level = 'DEBUG'
41
+
34
42
  is_default: bool = False
43
+
35
44
  userposts_add_pinned_post: bool = True
45
+
36
46
  auto_load: bool = True
37
47
  load_on_getitem: int | All | Batch | None = 1
38
48
  load_on_iter: int | All | Batch | None = BATCH
39
49
  force_load_lists: bool = False # load lists even if has_more is False
50
+
40
51
  debug_response: DebugResponseMode = DebugResponseMode.NO
52
+
41
53
  timeout: float = 30
42
54
  timeout_file: float = 120
55
+
43
56
  url: str = 'xn--d1ah4a.com'
44
57
  url_api: str | None = None
45
58
  user_agent: UserAgent | str = UserAgent.BROWSER
46
59
  solve_challenge: bool = True
60
+
47
61
  load_comments_from_post: bool = False
62
+
48
63
  parse_mode: ParseMode = ParseMode.NO
49
- rate_limit_wait: int | None = None # DEPRECATED
50
- retry_on_rate_limits: bool | None = None # DEPRECATED
64
+
51
65
  retry_enabled: bool = True
52
66
  retry_delay: float = 10 # delay before next attempt (after rate limit error) if retry_after is not provided in request
53
67
  retry_max_retries: int | None = None # none for no limit
54
68
  retry_exceptions: tuple[type[Exception]] | list[type[Exception]] | None = None
69
+ retry_max_retry_after: int = 500
70
+
55
71
  bypass_auth_level: bool = False
56
72
 
73
+ dwell_enabled: bool = True
74
+ dwell_max_buffer: int = 20
75
+ dwell_send_interval: float = 2
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
84
+
57
85
  def __post_init__(self):
58
86
  if self.rate_limit_default:
59
87
  self._rate_limit_default = self.rate_limit_default
@@ -79,28 +107,21 @@ class Config:
79
107
  case _:
80
108
  self._user_agent = self.user_agent
81
109
 
82
- if self.rate_limit_wait is not None:
83
- l.warning('config.rate_limit_wait is deprecated and will be removed in 2.4.0. Please use config.retry_delay')
84
- self.retry_delay = self.rate_limit_wait
85
- if self.retry_on_rate_limits is not None:
86
- l.warning('config.retry_on_rate_limits is deprecated and will be removed in 2.4.0. Please use config.retry_enabled')
87
- self.retry_enabled = self.retry_on_rate_limits
88
110
 
89
111
  self._retry_exceptions = (tuple(self.retry_exceptions) if isinstance(self.retry_exceptions, list) else self.retry_exceptions) or (RateLimitError, InternalError, RequestException)
90
112
 
91
113
 
92
114
 
93
115
  class Client:
94
- auth_level: AuthLevel = AuthLevel.NO
95
- access_token: str | None = None
96
- refresh_token: str | None = None
97
- _user = None
98
- _refreshing: bool = False
99
-
100
116
  def __init__(self, refresh: str | None = None, access: str | None = None, config: Config = Config()):
101
117
  l.info('init client refresh=%s access=%s', refresh is not None, access is not None)
102
118
  self.config = config
103
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] = []
104
125
 
105
126
  self.session = Session()
106
127
  adapter = HTTPAdapter(pool_connections=1, pool_maxsize=10, pool_block=False) # idk what is this, (claude added) just for better stability
@@ -120,6 +141,38 @@ class Client:
120
141
  if _default_client is None or config.is_default:
121
142
  set_default_client(self)
122
143
 
144
+ if self.auth_level > AuthLevel.NO and self.config.dwell_enabled:
145
+ self.dwell_tracker = DwellTracker(self)
146
+ self.dwell_tracker._start_timer()
147
+ else:
148
+ self.dwell_tracker = None
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
+
123
176
  def request(self, method: str, url: str, params: dict = {}, files: dict[str, tuple[str, BufferedReader | bytes]] = {}, level=AuthLevel.ACCESS):
124
177
  """Сделать запрос
125
178
 
@@ -137,17 +190,29 @@ class Client:
137
190
  if level >= AuthLevel.ACCESS and self.access_token is None and url != 'v1/auth/refresh':
138
191
  self.refresh_auth()
139
192
 
140
- def _fetch():
141
- return fetch(self, method, url, params, files)
193
+ return fetch(self, method, url, params, files)
142
194
 
143
- if not self.refresh_token or url == 'v1/auth/refresh':
144
- return _fetch()
195
+ def request_sse(self, url: str):
196
+ l.debug('sse %s', url)
145
197
 
146
- try:
147
- return _fetch()
148
- except (UnauthorizedError, AccessTokenExpiredError):
198
+ if self.access_token is None and url != 'v1/auth/refresh':
149
199
  self.refresh_auth()
150
- 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
+
151
216
 
152
217
  def refresh_auth(self) -> str:
153
218
  """Обновить access token
@@ -272,6 +337,12 @@ class Client:
272
337
  if hashtag:
273
338
  return hashtag[0]
274
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
+
275
346
  def get_follow_status(self, users: list[User | UUID | str] | User | UUID | str) -> dict[UUID, bool] | bool:
276
347
  """Получить статус подписки
277
348
 
@@ -1,3 +1,5 @@
1
+ from __future__ import annotations
2
+ from typing import TYPE_CHECKING
1
3
  from uuid import UUID
2
4
  from datetime import datetime
3
5
  from math import ceil
@@ -5,13 +7,14 @@ from math import ceil
5
7
  from pydantic import Field, BaseModel, field_validator
6
8
 
7
9
  from itd.base import ITDBaseModel, ITDList
8
- from itd.client import Client
9
10
  from itd.enums import CommentSorting, ReportTargetType, ReportReason
10
11
  from itd.report import Report
11
12
  from itd.utils import parse_datetime, to_nullable_uuid, format_attachments, ATTACHMENTS
12
13
  from itd.api.comments import get_comments, add_comment, add_reply_comment, get_replies, like_comment, unlike_comment, delete_comment
13
14
  from itd.user import User
14
15
  from itd.file import CommentAttach
16
+ if TYPE_CHECKING:
17
+ from itd.client import Client
15
18
 
16
19
 
17
20
  class Comment(ITDBaseModel):
@@ -130,6 +133,11 @@ class Comment(ITDBaseModel):
130
133
 
131
134
 
132
135
  class _CommentValidate(BaseModel, Comment):
136
+ @field_validator('attachments', mode='plain')
137
+ @classmethod
138
+ def validate_attachments(cls, attachments: list[dict]):
139
+ return [CommentAttach(attach) for attach in attachments]
140
+
133
141
  @field_validator('replies', mode='plain')
134
142
  @classmethod
135
143
  def validate_replies(cls, replies: list[dict]):