itd-sdk 2.5.2__tar.gz → 2.6.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 (46) hide show
  1. {itd_sdk-2.5.2 → itd_sdk-2.6.0}/PKG-INFO +1 -1
  2. {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/__init__.py +12 -6
  3. {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/_default.py +22 -2
  4. itd_sdk-2.6.0/itd/_limiter.py +96 -0
  5. {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/api/comments.py +13 -8
  6. itd_sdk-2.6.0/itd/api/notifications.py +62 -0
  7. {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/api/posts.py +0 -5
  8. {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/base.py +25 -14
  9. {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/client.py +30 -24
  10. {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/comment.py +15 -1
  11. {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/enums.py +10 -0
  12. {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/notification.py +144 -18
  13. {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/post.py +2 -3
  14. {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/user.py +24 -2
  15. {itd_sdk-2.5.2 → itd_sdk-2.6.0}/pyproject.toml +1 -1
  16. itd_sdk-2.5.2/itd/_limiter.py +0 -97
  17. itd_sdk-2.5.2/itd/api/notifications.py +0 -39
  18. {itd_sdk-2.5.2 → itd_sdk-2.6.0}/README.md +0 -0
  19. {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/api/__init__.py +0 -0
  20. {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/api/auth.py +0 -0
  21. {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/api/dwell.py +0 -0
  22. {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/api/etc.py +0 -0
  23. {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/api/files.py +0 -0
  24. {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/api/hashtags.py +0 -0
  25. {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/api/pins.py +0 -0
  26. {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/api/platform.py +0 -0
  27. {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/api/polls.py +0 -0
  28. {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/api/reports.py +0 -0
  29. {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/api/search.py +0 -0
  30. {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/api/sessions.py +0 -0
  31. {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/api/subscription.py +0 -0
  32. {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/api/users.py +0 -0
  33. {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/api/verification.py +0 -0
  34. {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/clan.py +0 -0
  35. {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/exceptions.py +0 -0
  36. {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/file.py +0 -0
  37. {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/hashtag.py +0 -0
  38. {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/logger.py +0 -0
  39. {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/pin.py +0 -0
  40. {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/poll.py +0 -0
  41. {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/report.py +0 -0
  42. {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/request.py +0 -0
  43. {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/session.py +0 -0
  44. {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/span.py +0 -0
  45. {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/utils.py +0 -0
  46. {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: itd-sdk
3
- Version: 2.5.2
3
+ Version: 2.6.0
4
4
  Summary: ITD client for python
5
5
  Keywords: itd,itdsocial
6
6
  Author: fi-res
@@ -36,7 +36,9 @@ from time import sleep
36
36
 
37
37
  __version__ = version("itd-sdk")
38
38
 
39
- from itd._default import limiters
39
+ from itd._default import LimiterConfig, limiters
40
+ from itd._default import set_config as set_limiter_config
41
+ from itd._limiter import BurstRateLimiter, HalfRateLimiter, IPRateLimiter, RateLimiter
40
42
  from itd.clan import Clan, TopClans
41
43
  from itd.client import Client as ITDClient
42
44
  from itd.client import Config as ITDConfig
@@ -50,14 +52,12 @@ from itd.user import Me, User, Users, get_follow_status
50
52
  from itd.version import Apps, Changelog
51
53
 
52
54
 
55
+ # call if you set auto_acquire=False in the end of cycle
53
56
  def acquire_limiters():
54
- try:
55
- sleep(max(limiter.get_delay() for limiter in limiters.values() if limiter.is_ran))
56
- except ValueError:
57
- pass
57
+ sleep(max((limiter.delay for limiter in limiters.values() if limiter.used), default=0))
58
58
 
59
59
  for limiter in limiters.values():
60
- limiter.is_ran = False
60
+ limiter.used = False
61
61
 
62
62
 
63
63
  __all__ = [
@@ -83,5 +83,11 @@ __all__ = [
83
83
  'Me',
84
84
  'Users',
85
85
  'get_follow_status',
86
+ 'set_limiter_config',
87
+ 'LimiterConfig',
88
+ 'BurstRateLimiter',
89
+ 'RateLimiter',
90
+ 'HalfRateLimiter',
91
+ 'IPRateLimiter',
86
92
  'acquire_limiters'
87
93
  ]
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from dataclasses import dataclass
3
4
  from typing import TYPE_CHECKING
4
5
 
5
6
  from itd._limiter import IPRateLimiter, RateLimiter
@@ -10,13 +11,32 @@ if TYPE_CHECKING:
10
11
  _default_client: Client | None = None
11
12
  limiters: dict[int, RateLimiter] = {}
12
13
  limits: dict[str, int] = {}
13
- ip_limiter = IPRateLimiter()
14
+
15
+
16
+ @dataclass
17
+ class LimiterConfig:
18
+ limiter: type[RateLimiter] | None = None
19
+ ip_limiter: IPRateLimiter | None = None
20
+ auto_acquire: bool = True
21
+
22
+
23
+ _config = LimiterConfig()
24
+
25
+
26
+ def set_config(config: LimiterConfig):
27
+ global _config
28
+ _config = config
29
+
30
+
31
+ def get_config() -> LimiterConfig:
32
+ global _config
33
+ return _config
14
34
 
15
35
 
16
36
  def get_default_client() -> Client:
17
37
  global _default_client
18
38
  if _default_client is None:
19
- raise
39
+ raise RuntimeError('No client inited')
20
40
  return _default_client
21
41
 
22
42
 
@@ -0,0 +1,96 @@
1
+ from abc import ABC, abstractmethod
2
+ from time import monotonic, sleep
3
+
4
+ from itd.logger import get_logger
5
+
6
+ l = get_logger('limiter')
7
+
8
+
9
+ class RateLimiter(ABC):
10
+ def __init__(self, capacity: int):
11
+ l.debug(r'\[%s] create rate limiter', capacity)
12
+ self.capacity = capacity
13
+ self._delay = 0
14
+ self.used = False
15
+
16
+ @abstractmethod
17
+ def sync(self, remaining: int): ... # sync remaining, calls after request
18
+
19
+ def acquire(self): # wait, calls before request
20
+ sleep(self.delay)
21
+
22
+ @abstractmethod
23
+ def on_limit(self): ... # calls when RateLimitError raised
24
+
25
+ def request(self): # call before request
26
+ self.used = True
27
+
28
+ @property
29
+ def delay(self):
30
+ return self._delay
31
+
32
+
33
+ class HalfRateLimiter(RateLimiter):
34
+ def __init__(self, capacity: int):
35
+ super().__init__(capacity)
36
+ self._delay = 60 / capacity
37
+ self.last_request = 0
38
+ self.requests = 0
39
+
40
+ @property
41
+ def delay(self):
42
+ return max(0.1, self._delay - (monotonic() - self.last_request))
43
+
44
+ def request(self):
45
+ super().request()
46
+ self.last_request = monotonic()
47
+ self.requests += 1
48
+
49
+ def sync(self, remaining: int):
50
+ # короче тут такая задумка что лимитер оставляет половину ременинга, то есть чем ты дальше от центра тем сильнее тебя пиздит лимитер
51
+ best = self.capacity / 2
52
+
53
+ def diff(a, b):
54
+ return max(a, b) - min(a, b)
55
+
56
+ l.info(r'\[%s] sync limiter total=%s remaining=%s diff=%s', self.capacity, self.requests, remaining, diff(best, remaining))
57
+ if remaining < best - 3 and self.requests > best:
58
+ self._delay = min(self._delay * (1 + (diff(best, remaining) / 20)), 30)
59
+ l.info(r'\[%s] slowdown delay=%.2f', self.capacity, self._delay)
60
+
61
+ elif remaining > best + 3 and self.requests > best:
62
+ self._delay = max(self._delay * (1 - (diff(best, remaining) / 50)), 0.1)
63
+ l.info(r'\[%s] speedup delay=%.2f', self.capacity, self._delay)
64
+
65
+ def on_limit(self):
66
+ self._delay *= 2
67
+ self._delay = min(self._delay, 30)
68
+
69
+
70
+ class BurstRateLimiter(RateLimiter):
71
+ def __init__(self, capacity: int):
72
+ super().__init__(capacity)
73
+ self.remaining = capacity
74
+
75
+ def sync(self, remaining: int):
76
+ l.debug(r'\[%s] sync limiter remaining=%s', self.capacity, remaining)
77
+ self.remaining = remaining
78
+
79
+ @property
80
+ def delay(self):
81
+ if self.remaining < 3:
82
+ return 60
83
+ return 0
84
+
85
+ def on_limit(self): ...
86
+
87
+
88
+ class IPRateLimiter:
89
+ def __init__(self):
90
+ self.requests = []
91
+
92
+ def acquire(self):
93
+ self.requests.append(monotonic())
94
+ self.requests = [request for request in self.requests if request > monotonic() - 60]
95
+ if len(self.requests) >= 90:
96
+ sleep(max(self.requests[0] + 60 - monotonic(), 0))
@@ -41,20 +41,25 @@ def get_comments(client: Client, post_id: UUID, cursor: int = 0, limit: int = 20
41
41
 
42
42
 
43
43
  @api_wrapper(NotFoundError('Comment'))
44
- def like_comment(client: Client, comment_id: UUID):
45
- return client.request('post', f'comments/{comment_id}/like')
44
+ def like_comment(client: Client, id: UUID):
45
+ return client.request('post', f'comments/{id}/like')
46
46
 
47
47
 
48
48
  @api_wrapper(NotFoundError('Comment'))
49
- def unlike_comment(client: Client, comment_id: UUID):
50
- return client.request('delete', f'comments/{comment_id}/like')
49
+ def unlike_comment(client: Client, id: UUID):
50
+ return client.request('delete', f'comments/{id}/like')
51
51
 
52
52
 
53
53
  @api_wrapper(NotFoundError('Comment'), AlreadyDeletedError('Comment'))
54
- def delete_comment(client: Client, comment_id: UUID):
55
- return client.request('delete', f'comments/{comment_id}')
54
+ def delete_comment(client: Client, id: UUID):
55
+ return client.request('delete', f'comments/{id}')
56
56
 
57
57
 
58
58
  @api_wrapper(ValidationError(), NotFoundError('Comment'))
59
- def get_replies(client: Client, comment_id: UUID, page: int = 1, limit: int = 50):
60
- return client.request('get', f'comments/{comment_id}/replies', {'page': page, 'limit': limit})
59
+ def get_replies(client: Client, id: UUID, page: int = 1, limit: int = 50):
60
+ return client.request('get', f'comments/{id}/replies', {'page': page, 'limit': limit})
61
+
62
+
63
+ @api_wrapper(ValidationError(), NotFoundError('Comment'), ForbiddenError('edit comment'))
64
+ def edit_comment(client: Client, id: UUID, content: str):
65
+ return client.request('patch', f'comments/{id}', {'content': content})
@@ -0,0 +1,62 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+ from uuid import UUID
5
+
6
+ from itd.base import api_wrapper
7
+ from itd.exceptions import NotFoundError
8
+
9
+ if TYPE_CHECKING:
10
+ from itd.client import Client
11
+ from itd.notification import NotificationsSettings
12
+
13
+
14
+ @api_wrapper()
15
+ def get_notifications(client: Client, limit: int = 20, offset: int = 0):
16
+ return client.request('get', 'notifications', {'limit': limit, 'offset': offset})
17
+
18
+
19
+ @api_wrapper(NotFoundError('Notification', json_check=lambda json: json.get('success') is False))
20
+ def mark_as_read(client: Client, id: UUID):
21
+ return client.request('post', f'notifications/{id}/read')
22
+
23
+
24
+ @api_wrapper()
25
+ def mark_all_as_read(client: Client):
26
+ return client.request('post', 'notifications/read-all')
27
+
28
+
29
+ @api_wrapper()
30
+ def get_unread_notifications_count(client: Client):
31
+ return client.request('get', 'notifications/count')
32
+
33
+
34
+ def stream_notifications(client: Client):
35
+ return client.request_sse('notifications/stream')
36
+
37
+
38
+ def get_notifications_settings(client: Client):
39
+ return client.request('get', 'notifications/settings')
40
+
41
+
42
+ def update_notifications_settings(client: Client, settings: NotificationsSettings, *, old: bool = True, new: bool = True):
43
+ from itd.notification import _NotificationsSettingsNew, _NotificationsSettingsNewPreferences, _NotificationsSettingsOld # жду фикс circular import день 67
44
+
45
+ data = {}
46
+ if old:
47
+ print(settings.follows)
48
+
49
+ data.update(_NotificationsSettingsOld.model_validate(settings, from_attributes=True).model_dump(mode='json', by_alias=True))
50
+ print(settings.follows)
51
+
52
+ if new:
53
+ data.update(
54
+ _NotificationsSettingsNew(
55
+ web_enabled=settings.web_enabled,
56
+ sound_enabled=settings.sound,
57
+ preferences=_NotificationsSettingsNewPreferences.model_validate(settings, from_attributes=True),
58
+ enabled=settings.enabled
59
+ ).model_dump(mode='json', by_alias=True)
60
+ )
61
+
62
+ return client.request('put', 'notifications/settings', data)
@@ -97,11 +97,6 @@ def repost(client: Client, id: UUID, content: str | None = None):
97
97
  return client.request('post', f'posts/{id}/repost', data)
98
98
 
99
99
 
100
- @api_wrapper(NotFoundError('Post'))
101
- def view_post(client: Client, id: UUID):
102
- return client.request('post', f'posts/{id}/view')
103
-
104
-
105
100
  @api_wrapper(ValidationError(), NotFoundError('User'))
106
101
  def get_liked_posts(client: Client, username_or_id: str | UUID, cursor: datetime | None = None, limit: int = 20):
107
102
  return client.request('get', f'posts/user/{username_or_id}/liked', {'limit': limit, 'cursor': cursor})
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import inspect
3
4
  from abc import abstractmethod
4
5
  from functools import wraps
5
6
  from time import sleep
@@ -9,8 +10,7 @@ from pydantic import BaseModel
9
10
  from requests import Response
10
11
  from requests.exceptions import JSONDecodeError
11
12
 
12
- from itd._default import get_default_client, ip_limiter, limiters, limits
13
- from itd._limiter import BurstRateLimiter, SafeRateLimiter
13
+ from itd._default import get_config, get_default_client, limiters, limits
14
14
  from itd.enums import ALL, BATCH, All, Batch, DebugResponseMode, LoadStatus
15
15
  from itd.exceptions import DEFAULT_ERRORS, AccessTokenExpiredError, AccountDeletedError, ITDException, RateLimitError, UnauthorizedError, ValidationError
16
16
  from itd.logger import get_logger
@@ -50,6 +50,7 @@ class ITDBaseModel:
50
50
 
51
51
  def __init__(self, client: Client | None = None) -> None:
52
52
  self._client = client or get_default_client()
53
+
53
54
  self._loaded_attrs: set[str] = set()
54
55
 
55
56
  def __setattr__(self, name: str, value: Any) -> None:
@@ -81,7 +82,7 @@ class ITDBaseModel:
81
82
  validated = self._validator().model_validate(data) # ty: ignore[missing-argument]
82
83
  self._loaded_attrs = validated.model_fields_set # значения автоматом добавляются через setattr # так значит это же тогда надо закоментить? # хз наверн
83
84
  for name, value in validated.__dict__.items():
84
- setattr(self, name, value)
85
+ object.__setattr__(self, name, value)
85
86
 
86
87
  self._post_refresh()
87
88
 
@@ -97,7 +98,6 @@ class ITDBaseModel:
97
98
  if not TYPE_CHECKING:
98
99
 
99
100
  def __getattribute__(self, name: str) -> Any:
100
-
101
101
  try:
102
102
  value = object.__getattribute__(self, name)
103
103
  exc = None
@@ -105,6 +105,8 @@ class ITDBaseModel:
105
105
  value = None
106
106
  exc = e
107
107
 
108
+ if name == 'is_following':
109
+ l.debug(_getattr(self, '_loaded_attrs'))
108
110
  if (
109
111
  _getattr(self, '_refreshable')
110
112
  and not name.startswith('_')
@@ -306,7 +308,7 @@ def _filter_bytes(args: tuple):
306
308
  return filtered
307
309
 
308
310
 
309
- # user calls `Me` -> model calls `get_me` -> `api_wrapper` wrapper: (`get_me` -> `client.request` -> `fetch` -> responses 401 -> `refresh_auth` from `api_wrapper` -> `client.resuest` -> `fetch` -> token refreshed -> `api_wrapper` backs to main query -> `get_me` -> `client.request` -> `fetch` -> user fetched) -> model recieves data -> pydantic fills model # hell what the monster i did
311
+ # user calls `Me` -> model calls `get_me` -> `api_wrapper` wrapper: (`get_me` -> `client.request` -> `fetch` -> responses 401 -> `refresh_auth` from `api_wrapper` -> `client.resuest` -> `fetch` -> token refreshed -> `api_wrapper` backs to main query -> `get_me` -> `client.request` -> `fetch` -> user fetched) -> model recieves data -> pydantic fills model
310
312
  def api_wrapper(*exceptions: ITDException):
311
313
  """Декоратор для отлавливания ошибок
312
314
 
@@ -321,9 +323,19 @@ def api_wrapper(*exceptions: ITDException):
321
323
 
322
324
  def exec():
323
325
  l.info('exec %s %s %s', func.__name__, _filter_bytes(args), kwargs)
324
- if client.config.auto_acquire and name in limits:
325
- limiters[limits[name]].acquire()
326
- ip_limiter.acquire()
326
+
327
+ config = get_config()
328
+ is_first = True
329
+ if name in limits and limits[name] in limiters:
330
+ limiter = limiters[limits[name]]
331
+ if config.auto_acquire:
332
+ limiter.acquire()
333
+ is_first = not limiter.used
334
+ limiter.request()
335
+
336
+ ip_limiter = config.ip_limiter
337
+ if ip_limiter:
338
+ ip_limiter.acquire()
327
339
 
328
340
  res: Response = func(client, *args, **kwargs)
329
341
 
@@ -337,12 +349,10 @@ def api_wrapper(*exceptions: ITDException):
337
349
  limit = int(res.headers.get('x-ratelimit-limit', 0))
338
350
  limits[name] = limit
339
351
 
340
- if client.config._anti_rate_limit:
341
- if client.config._burst_requests and limit not in limiters:
342
- limiters[limit] = BurstRateLimiter(limit)
352
+ if limit not in limiters and config.limiter is not None:
353
+ limiters[limit] = config.limiter(limit)
343
354
 
344
- elif limit not in limiters:
345
- limiters[limit] = SafeRateLimiter(limit)
355
+ if limit in limiters and (config.auto_acquire or is_first):
346
356
  limiters[limit].sync(remaining)
347
357
 
348
358
  if client.config.debug_response == DebugResponseMode.BEFORE:
@@ -409,7 +419,8 @@ def api_wrapper(*exceptions: ITDException):
409
419
  retry_after = getattr(e, 'retry_after') or client.config.retry_delay
410
420
  l.warning('%s on %s: wait %ss', e.__class__.__name__, func.__name__, retry_after)
411
421
  sleep(retry_after)
412
- limiters[name].on_limit()
422
+ if name in limits and limits[name] in limiters:
423
+ limiters[limits[name]].on_limit()
413
424
 
414
425
  return wrapper
415
426
 
@@ -12,6 +12,7 @@ from requests import Session
12
12
  from requests.adapters import HTTPAdapter
13
13
  from requests.utils import default_user_agent
14
14
 
15
+ from itd import BurstRateLimiter, HalfRateLimiter, IPRateLimiter, LimiterConfig, set_limiter_config
15
16
  from itd._default import _default_client, set_default_client
16
17
  from itd.api.auth import change_password, logout, refresh_token
17
18
  from itd.api.posts import get_stats
@@ -71,16 +72,17 @@ class Config:
71
72
  # для реальных клиентов включен калбэк на ошибки (сами ошибки не выбрасываются) и выключен логгер. Передает ошибки в калбэк, в консоль ничего не пишет, при ошибках сети падает.
72
73
  # для ботов включен отлов всех ошибок, а также калбэк при ошибках сети (шоб отправлять в тг например), включен логгер на инфо. Показывает ошибки в консоли, но не завершшает скрипт.
73
74
  # для одноразовых скриптов стандартное поведение, отловов ошибок нет, включен логгер на дебаг.
75
+ # ^ это если что просто мысли, не обращайте внимания
74
76
  client_type: Literal['client', 'bot', 'onetime'] = 'onetime'
75
77
 
76
78
  rate_limit: RateLimitMode | None = None # deprecated
77
79
  rate_limit_default: int | None = None # deprecated
78
80
  rate_limit_actions: dict[str, float | int] | None = None # deprecated
79
- anti_rate_limit: bool | None = None
80
- burst_requests: bool | None = None
81
- anti_ip_ban: bool = True
82
- limit_coefficient: float | None = None
83
- auto_acquire: bool = True
81
+ anti_rate_limit: bool | None = None # deprecated
82
+ burst_requests: bool | None = None # deprecated
83
+ anti_ip_ban: bool | None = None # deprecated
84
+ limit_coefficient: float | None = None # deprecated
85
+ auto_acquire: bool | None = None
84
86
 
85
87
  # enable_logging: bool | None = None
86
88
  # logging_level = 'DEBUG'
@@ -163,6 +165,29 @@ class Config:
163
165
  l.warning('config.rate_limit_default is deprecated and will be removed in 2.7.0.')
164
166
  if self.rate_limit_actions is not None:
165
167
  l.warning('config.rate_limit_actions is deprecated and will be removed in 2.7.0.')
168
+ if self.anti_ip_ban is not None:
169
+ l.warning(
170
+ f'config.anti_ip_ban is deprecated and will be removed in 2.7.0. '
171
+ f'Please use set_limiter_config(LimiterConfig(ip_limiter={"IPRateLimiter" if self.anti_ip_ban else None})).'
172
+ )
173
+ set_limiter_config(LimiterConfig(ip_limiter=IPRateLimiter() if self.anti_ip_ban else None))
174
+ if self.anti_rate_limit is not None:
175
+ l.warning(
176
+ f'config.anti_rate_limit is depreacred and will be removed in 2.7.0. '
177
+ f'Please use set_limiter_config(limiter={"HalfRateLimiter" if self.anti_rate_limit else None}).'
178
+ )
179
+ set_limiter_config(LimiterConfig(limiter=HalfRateLimiter if self.anti_rate_limit else None))
180
+ if self.burst_requests is not None:
181
+ l.warning(
182
+ f'config.anti_rate_limit is depreacred and will be removed in 2.7.0. '
183
+ f'Please use set_limiter_config(limiter={"BurstRateLimiter" if self.burst_requests else None}).'
184
+ )
185
+ set_limiter_config(LimiterConfig(limiter=BurstRateLimiter if self.burst_requests else None))
186
+ if self.limit_coefficient is not None:
187
+ l.warning(
188
+ 'config.limit_coefficient is deprecated and will be removed in 2.7.0. '
189
+ 'Please define your own rate limiter that will override sync function of RateLimiter class and apply coefficient to remaining.'
190
+ )
166
191
 
167
192
  if self.timeout is None:
168
193
  match self.client_type:
@@ -185,28 +210,9 @@ class Config:
185
210
  else:
186
211
  self._post_update_stats = self.post_update_stats
187
212
 
188
- if self.anti_rate_limit is None:
189
- self._anti_rate_limit = self.client_type == 'bot'
190
- else:
191
- self._anti_rate_limit = self.anti_rate_limit
192
213
  if self.load_comments_from_post is not None:
193
214
  l.warning('load_comments_from_post is deprecated and will be removed in 2.7.0.')
194
215
 
195
- if self.burst_requests is None:
196
- self._burst_requests = self.client_type != 'bot'
197
- else:
198
- self._burst_requests = self.burst_requests
199
-
200
- if self.limit_coefficient is None:
201
- if self.client_type == 'bot':
202
- self._limit_coefficient = 0.75
203
- elif self.client_type == 'client':
204
- self._limit_coefficient = 0.9
205
- else:
206
- self._limit_coefficient = 1
207
- else:
208
- self._limit_coefficient = self.limit_coefficient
209
-
210
216
 
211
217
  class AccessToken(BaseModel):
212
218
  roles: list[Role] = [Role.USER]
@@ -6,7 +6,7 @@ from uuid import UUID
6
6
 
7
7
  from pydantic import BaseModel, Field, field_validator
8
8
 
9
- from itd.api.comments import add_comment, add_reply_comment, delete_comment, get_comments, get_replies, like_comment, unlike_comment
9
+ from itd.api.comments import add_comment, add_reply_comment, delete_comment, edit_comment, get_comments, get_replies, like_comment, unlike_comment
10
10
  from itd.base import ITDBaseModel, ITDList
11
11
  from itd.enums import CommentSorting, ReportReason, ReportTargetType
12
12
  from itd.file import CommentAttach
@@ -115,6 +115,20 @@ class Comment(ITDBaseModel):
115
115
  """
116
116
  delete_comment(client or self._client, self.id)
117
117
 
118
+ def edit(self, content: str, *, client: Client | None = None) -> datetime:
119
+ """Изменить комментарий
120
+
121
+ Args:
122
+ content (str): Новое содержимое
123
+ client (Client | None, optional): Клиент. Defaults to None.
124
+
125
+ Returns:
126
+ datetime: Дата изменения
127
+ """
128
+ edited_at = edit_comment(client or self.client, self.id, content).json()['editedAt']
129
+ self.content = content
130
+ return parse_datetime(edited_at)
131
+
118
132
  @classmethod
119
133
  def new(cls, post: Post, content: str | None = None, attachments: ATTACHMENTS = [], client: Client | None = None):
120
134
  instance = cls.__new__(cls)
@@ -18,6 +18,16 @@ class ViewSource(Enum):
18
18
  SEARCH = 8
19
19
 
20
20
 
21
+ class LastSeenUnit(Enum):
22
+ JUST_NOW = 'just_now'
23
+ RECENTLY = 'recently'
24
+ MINUTES = 'minutes'
25
+ HOURS = 'hours'
26
+ THIS_WEEK = 'this_week'
27
+ THIS_MONTH = 'this_month'
28
+ LONG_AGO = 'long_ago'
29
+
30
+
21
31
  class ViewReason(Enum):
22
32
  NORMAL = 0 # стандартное
23
33
  BLUR = 1 # страница скрыта (listener blur)
@@ -3,16 +3,24 @@ from __future__ import annotations
3
3
  from datetime import datetime
4
4
  from json import loads
5
5
  from threading import Thread
6
- from typing import TYPE_CHECKING, Iterator, Literal, cast
6
+ from typing import TYPE_CHECKING, Any, Callable, Iterator, Literal, cast
7
7
  from uuid import UUID
8
8
 
9
9
  from pydantic import BaseModel, Field, field_validator
10
10
  from sseclient import SSEClient
11
11
 
12
- from itd.api.notifications import get_notifications, get_unread_notifications_count, mark_all_as_read, mark_as_read, stream_notifications
12
+ from itd.api.notifications import (
13
+ get_notifications,
14
+ get_notifications_settings,
15
+ get_unread_notifications_count,
16
+ mark_all_as_read,
17
+ mark_as_read,
18
+ stream_notifications,
19
+ update_notifications_settings
20
+ )
13
21
  from itd.base import ITDBaseModel, ITDList
14
22
  from itd.client import Client
15
- from itd.enums import NotificationTargetType, NotificationType
23
+ from itd.enums import LoadStatus, NotificationTargetType, NotificationType
16
24
  from itd.logger import get_logger
17
25
  from itd.user import User
18
26
 
@@ -23,9 +31,96 @@ if TYPE_CHECKING:
23
31
  l = get_logger('notifications') # noqa: E741
24
32
 
25
33
 
34
+ class NotificationsSettings(ITDBaseModel):
35
+ _validator = lambda _: _NotificationsSettingsValidate
36
+ enabled: bool
37
+ web_enabled: bool = Field(True, alias='webEnabled')
38
+ sound: bool = Field(alias='sound')
39
+ follows: bool
40
+ wall_posts: bool = Field(alias='wallPosts')
41
+ likes: bool
42
+ comments: bool
43
+ replies: bool = True
44
+ mentions: bool
45
+
46
+ def _refresh(self, *, client: Client):
47
+ return get_notifications_settings(client).json()
48
+
49
+ def update(
50
+ self,
51
+ enabled: bool | None = None,
52
+ web_enabled: bool | None = None,
53
+ sound: bool | None = None,
54
+ follows: bool | None = None,
55
+ wall_posts: bool | None = None,
56
+ likes: bool | None = None,
57
+ comments: bool | None = None,
58
+ replies: bool | None = None,
59
+ mentions: bool | None = None,
60
+ client: Client | None = None,
61
+ old: bool = True,
62
+ new: bool = True
63
+ ):
64
+ if self.load_status != LoadStatus.FULL:
65
+ self.refresh()
66
+ if enabled is not None:
67
+ self.enabled = enabled
68
+ if web_enabled is not None:
69
+ self.web_enabled = web_enabled
70
+ if sound is not None:
71
+ self.sound = sound
72
+ if follows is not None:
73
+ self.follows = follows
74
+ if wall_posts is not None:
75
+ self.wall_posts = wall_posts
76
+ if likes is not None:
77
+ self.likes = likes
78
+ if comments is not None:
79
+ self.comments = comments
80
+ if replies is not None:
81
+ self.replies = replies
82
+ if mentions is not None:
83
+ self.mentions = mentions
84
+ self.update_from_fields(client, old=old, new=new)
85
+
86
+ def update_from_fields(self, client: Client | None = None, *, old: bool = True, new: bool = True):
87
+ update_notifications_settings(client or self.client, self, old=old, new=new)
88
+
89
+
90
+ class _NotificationsSettingsValidate(BaseModel, NotificationsSettings):
91
+ pass
92
+
93
+
94
+ class _NotificationsSettingsOld(BaseModel):
95
+ enabled: bool
96
+ sound: bool
97
+ follows: bool
98
+ wall_posts: bool = Field(serialization_alias='wallPosts')
99
+ likes: bool
100
+ comments: bool
101
+ mentions: bool
102
+
103
+
104
+ class _NotificationsSettingsNewPreferences(BaseModel):
105
+ follows: bool
106
+ reactions: bool = Field(validation_alias='likes')
107
+ comments: bool
108
+ replies: bool
109
+ mentions: bool
110
+ wall_posts: bool = Field(serialization_alias='wallPosts')
111
+
112
+
113
+ class _NotificationsSettingsNew(BaseModel):
114
+ web_enabled: bool = Field(serialization_alias='webEnabled')
115
+ sound_enabled: bool = Field(serialization_alias='soundEnabled')
116
+ preferences: _NotificationsSettingsNewPreferences
117
+ enabled: bool
118
+
119
+
26
120
  class Notification(ITDBaseModel):
27
121
  _refreshable = False
28
- _notifications: Notifications | None = None
122
+ _notifications: Notifications
123
+ _validator = lambda _: _NotificationValidate
29
124
 
30
125
  id: UUID
31
126
  type: NotificationType
@@ -42,30 +137,30 @@ class Notification(ITDBaseModel):
42
137
  actor: User
43
138
  sound: bool = False # for notifications from stream
44
139
 
45
- def __init__(self, notification: dict, notifications: Notifications | None = None, client: Client | None = None) -> None:
46
- super().__init__(client)
47
- self._notifications = notifications
48
-
49
- for name, value in _NotificationValidate.model_validate(notification).__dict__.items():
50
- setattr(self, name, value)
51
-
52
140
  def __hash__(self):
53
141
  return int(self.id)
54
142
 
143
+ @classmethod
144
+ def from_dict(cls, data: dict, notifications: Notifications, *, client: Client | None = None): # ty: ignore # who asked
145
+ instance = super().from_dict(data, client=client)
146
+ instance._notifications = notifications
147
+ return instance
148
+
55
149
  def read(self, client: Client | None = None) -> None:
56
150
  mark_as_read(client or self.client, self.id)
57
151
 
58
- if not self.is_read and self._notifications and self._notifications._unread: # check if already read and has notifications and unread loaded
152
+ if not self.is_read and self._notifications._unread: # check if already read and has notifications and unread loaded
59
153
  self._notifications._unread -= 1
60
154
 
61
155
  self.is_read = True
62
156
  self.read_at = datetime.now()
63
157
 
64
- def get_text(self, avatar: bool = False) -> str:
158
+ def get_text(self, actor: bool = True, avatar: bool = False) -> str:
65
159
  text = ''
66
160
  if avatar:
67
161
  text += self.actor.avatar + ' '
68
- text += self.actor.display_name + ' '
162
+ if actor:
163
+ text += self.actor.display_name + ' '
69
164
 
70
165
  match self.type:
71
166
  case NotificationType.FOLLOW:
@@ -107,6 +202,9 @@ class Notification(ITDBaseModel):
107
202
  case NotificationType.MENTION | NotificationType.COMMENT_MENTION:
108
203
  return 'purple'
109
204
 
205
+ def __str__(self):
206
+ return self.get_text()
207
+
110
208
 
111
209
  class _NotificationValidate(BaseModel, Notification):
112
210
  @field_validator('actor', mode='plain')
@@ -116,9 +214,14 @@ class _NotificationValidate(BaseModel, Notification):
116
214
 
117
215
 
118
216
  class Notifications(ITDList[Notification]):
119
- _unread: int | None = None
120
217
  cursor: int = 0
121
218
 
219
+ def __init__(self, client: Client | None = None):
220
+ super().__init__(client=client)
221
+ self._unread: int | None = None
222
+ self._callbacks: dict[str | None, Callable[[Notification], Any]] = {}
223
+ self.settings = NotificationsSettings(client=client)
224
+
122
225
  def _fetch(self, client: Client, limit: int) -> dict:
123
226
  return get_notifications(client, limit, len(self)).json()
124
227
 
@@ -134,7 +237,7 @@ class Notifications(ITDList[Notification]):
134
237
  return data['hasMore']
135
238
 
136
239
  def _to_models(self, objects: list, client: Client):
137
- return [Notification(notification, self, client) for notification in objects]
240
+ return [Notification.from_dict(notification, self, client=client) for notification in objects]
138
241
 
139
242
  def read_all(self):
140
243
  mark_all_as_read(self.client)
@@ -154,15 +257,21 @@ class Notifications(ITDList[Notification]):
154
257
  data = loads(event.data)
155
258
 
156
259
  if 'userId' in data and 'timestamp' in data and 'type' not in data:
157
- l.debug('got init message')
260
+ l.debug('got init message %s', data)
158
261
  continue # initial message
159
262
 
160
- notification = Notification(data, self, self.client)
263
+ notification: Notification = Notification.from_dict(data, self, client=self.client)
161
264
  self.insert(0, notification)
265
+ if self._unread is not None:
266
+ self._unread += 1
162
267
 
163
268
  l.info('new notification type=%s', notification.type.value)
164
269
  exec(f'self.on_{notification.type.value}(notification)')
165
270
  self.on_notification(notification)
271
+ if None in self._callbacks:
272
+ self._callbacks[None](notification)
273
+ if notification.type.value in self._callbacks:
274
+ self._callbacks[notification.type.value](notification)
166
275
 
167
276
  yield notification
168
277
 
@@ -207,3 +316,20 @@ class Notifications(ITDList[Notification]):
207
316
  def on_wall_post(self, notification: Notification, /) -> None: ...
208
317
 
209
318
  def on_notification(self, notification: Notification, /) -> None: ...
319
+
320
+ def on(
321
+ self,
322
+ type: Literal[
323
+ 'like', 'comment', 'reply', 'repost', 'mention', 'follow', 'follow_request', 'follow_accepted', 'comment_like', 'comment_mention', 'wall_post'
324
+ ]
325
+ | None = None
326
+ ):
327
+ def decorator(func: Callable[[Notification], Any]):
328
+ self._callbacks[type] = func
329
+
330
+ def wrapper(*args, **kwargs):
331
+ return func(*args, **kwargs)
332
+
333
+ return wrapper
334
+
335
+ return decorator
@@ -25,8 +25,7 @@ from itd.api.posts import (
25
25
  repost,
26
26
  restore_post,
27
27
  unlike_post,
28
- unpin_post,
29
- view_post
28
+ unpin_post
30
29
  )
31
30
  from itd.base import ITDBaseModel, ITDList
32
31
  from itd.comment import Comment, Comments
@@ -459,7 +458,7 @@ class Post(ITDBaseModel):
459
458
 
460
459
  c.dwell_tracker.record_view(self.id, self.vs, duration, entered_at, exited_at, self.source, self.source_context, reason)
461
460
  else:
462
- view_post(c, self.id)
461
+ l.error('old post viewing is no more supported. Please enable dwell_tracker in config.')
463
462
 
464
463
  if c == self.client:
465
464
  self.is_viewed = True
@@ -29,7 +29,7 @@ from itd.api.users import (
29
29
  )
30
30
  from itd.api.users import get_follow_status as _get_follow_status
31
31
  from itd.base import ITDBaseModel, ITDList
32
- from itd.enums import AccessType, LoadStatus, ReportReason, ReportTargetType, Role, Unset
32
+ from itd.enums import AccessType, LastSeenUnit, LoadStatus, ReportReason, ReportTargetType, Role, Unset
33
33
  from itd.exceptions import AccountDeletedError, NotFoundError, PinNotOwnedError
34
34
  from itd.pin import Pin
35
35
  from itd.poll import NewPoll
@@ -173,6 +173,28 @@ class _SubscriptionValidate(BaseModel, Subscription):
173
173
  pass
174
174
 
175
175
 
176
+ class LastSeen(BaseModel):
177
+ unit: LastSeenUnit
178
+ value: int | None = None
179
+
180
+ def __str__(self):
181
+ match self.unit:
182
+ case LastSeenUnit.JUST_NOW:
183
+ return 'только что'
184
+ case LastSeenUnit.RECENTLY:
185
+ return 'недавно'
186
+ case LastSeenUnit.MINUTES:
187
+ return f'{self.value} минут назад'
188
+ case LastSeenUnit.HOURS:
189
+ return f'{self.value} часов назад'
190
+ case LastSeenUnit.THIS_WEEK:
191
+ return 'на этой неделе'
192
+ case LastSeenUnit.THIS_MONTH:
193
+ return 'в этом месяце'
194
+ case LastSeenUnit.LONG_AGO:
195
+ return 'давно' # гавно
196
+
197
+
176
198
  class _UserBase(ITDBaseModel):
177
199
  _identifier: str | UUID
178
200
 
@@ -253,7 +275,7 @@ class User(_UserBase):
253
275
  is_private: bool | None = Field(None, alias='isPrivate') # none if following or blocked
254
276
 
255
277
  is_subscribed: bool = Field(False, alias='hasNuksta')
256
- last_seen: datetime | dict | None = Field(None, alias='lastSeen') # none if hidden or blocked
278
+ last_seen: datetime | LastSeen | None = Field(None, alias='lastSeen') # none if hidden or blocked
257
279
  online: bool = False
258
280
 
259
281
  pinned_post_id: UUID | None = Field(None, alias='pinnedPostId') # none if no or blocked
@@ -14,7 +14,7 @@ version_provider = "uv"
14
14
 
15
15
  [project]
16
16
  name = "itd-sdk"
17
- version = "2.5.2"
17
+ version = "2.6.0"
18
18
  description = "ITD client for python"
19
19
  readme = "README.md"
20
20
  authors = [
@@ -1,97 +0,0 @@
1
- from time import monotonic, sleep
2
-
3
- from itd.logger import get_logger
4
-
5
- l = get_logger('limiter')
6
-
7
-
8
- class RateLimiter:
9
- window: int = 65
10
-
11
- def __init__(self, capacity: int):
12
- l.debug(r'\[%s] create rate limiter', capacity)
13
- self.capacity = capacity
14
- self.is_ran = True
15
-
16
- def sync(self, remaining: int): ...
17
- def acquire(self): ...
18
- def on_limit(self): ...
19
- def get_delay(self): ...
20
-
21
-
22
- class SafeRateLimiter(RateLimiter):
23
- def __init__(self, capacity: int):
24
- super().__init__(capacity)
25
- self.delay = 60 / capacity
26
- self.last_request = monotonic()
27
- self.requests = 0
28
- self.is_ran = False
29
-
30
- def get_delay(self):
31
- self.requests += 1
32
- self.last_request = monotonic()
33
- return max(0.1, self.delay - (monotonic() - self.last_request))
34
-
35
- def acquire(self):
36
- l.debug(r'\[%s] acquire limiter delay=%s', self.capacity, round(self.delay, 2))
37
- sleep(self.get_delay())
38
-
39
- def sync(self, remaining: int):
40
- l.info(r'\[%s] sync limiter remaining=%s', self.capacity, remaining)
41
- if remaining < self.capacity * 0.2:
42
- self.delay *= 1 + 1 / (remaining or 0.5)
43
- l.info(r'\[%s] increase delay=%s', self.capacity, round(self.delay, 2))
44
- if remaining > self.capacity * 0.9:
45
- self.delay *= 0.7
46
- l.info(r'\[%s] decrease delay=%s', self.capacity, round(self.delay, 2))
47
- elif 10 < self.requests < 500 and remaining > self.capacity * 0.5:
48
- self.delay -= 1 / self.requests
49
- self.delay = max(0.1, min(self.delay, 30))
50
- self.is_ran = True
51
-
52
- def on_limit(self):
53
- self.delay *= 2
54
- self.delay = min(self.delay, 30)
55
-
56
-
57
- class BurstRateLimiter(RateLimiter):
58
- def __init__(self, capacity: int):
59
- super().__init__(capacity)
60
- self.requests = []
61
-
62
- def sync(self, remaining: int):
63
- l.debug(r'\[%s] sync limiter remaining=%s', self.capacity, remaining)
64
-
65
- used = self.capacity - remaining
66
- if used > 0:
67
- self.requests = self.requests[-used:] # ai
68
- while len(self.requests) < used:
69
- self.requests.append(monotonic())
70
-
71
- def acquire(self):
72
- l.debug(r'\[%s] acquire limiter', self.capacity)
73
-
74
- self.requests = [request for request in self.requests if request > monotonic() - self.window]
75
- while len(self.requests) >= self.capacity:
76
- self.requests = [request for request in self.requests if request > monotonic() - self.window]
77
- if self.requests:
78
- sleep(max(self.requests[0] + self.window - monotonic(), 0.1))
79
-
80
-
81
- class IPRateLimiter:
82
- window: int = 60
83
- max_requests: int = 90
84
-
85
- def __init__(self):
86
- self.requests = []
87
-
88
- def acquire(self):
89
- self.requests.append(monotonic())
90
- self.requests = [request for request in self.requests if request > monotonic() - self.window]
91
- while len(self.requests) >= self.max_requests:
92
- self.requests = [request for request in self.requests if request > monotonic() - self.window]
93
- if self.requests:
94
- sleep(self.get_delay())
95
-
96
- def get_delay(self):
97
- return max(self.requests[0] + self.window - monotonic(), 0.1)
@@ -1,39 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from typing import TYPE_CHECKING
4
- from uuid import UUID
5
-
6
- from itd.base import api_wrapper
7
- from itd.exceptions import NotFoundError
8
-
9
- if TYPE_CHECKING:
10
- from itd.client import Client
11
-
12
-
13
- @api_wrapper()
14
- def get_notifications(client: Client, limit: int = 20, offset: int = 0):
15
- return client.request('get', 'notifications', {'limit': limit, 'offset': offset})
16
-
17
-
18
- @api_wrapper(NotFoundError('Notification', json_check=lambda json: json.get('success') is False))
19
- def mark_as_read(client: Client, id: UUID):
20
- return client.request('post', f'notifications/{id}/read')
21
-
22
-
23
- @api_wrapper()
24
- def mark_all_as_read(client: Client):
25
- return client.request('post', 'notifications/read-all')
26
-
27
-
28
- @api_wrapper()
29
- def get_unread_notifications_count(client: Client):
30
- return client.request('get', 'notifications/count')
31
-
32
-
33
- def stream_notifications(client: Client):
34
- """Получить SSE поток уведомлений
35
-
36
- Returns:
37
- Response: Streaming response для SSE
38
- """
39
- return client.request_sse('notifications/stream')
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes