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.
- {itd_sdk-2.5.2 → itd_sdk-2.6.0}/PKG-INFO +1 -1
- {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/__init__.py +12 -6
- {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/_default.py +22 -2
- itd_sdk-2.6.0/itd/_limiter.py +96 -0
- {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/api/comments.py +13 -8
- itd_sdk-2.6.0/itd/api/notifications.py +62 -0
- {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/api/posts.py +0 -5
- {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/base.py +25 -14
- {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/client.py +30 -24
- {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/comment.py +15 -1
- {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/enums.py +10 -0
- {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/notification.py +144 -18
- {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/post.py +2 -3
- {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/user.py +24 -2
- {itd_sdk-2.5.2 → itd_sdk-2.6.0}/pyproject.toml +1 -1
- itd_sdk-2.5.2/itd/_limiter.py +0 -97
- itd_sdk-2.5.2/itd/api/notifications.py +0 -39
- {itd_sdk-2.5.2 → itd_sdk-2.6.0}/README.md +0 -0
- {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/api/__init__.py +0 -0
- {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/api/auth.py +0 -0
- {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/api/dwell.py +0 -0
- {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/api/etc.py +0 -0
- {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/api/files.py +0 -0
- {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/api/hashtags.py +0 -0
- {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/api/pins.py +0 -0
- {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/api/platform.py +0 -0
- {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/api/polls.py +0 -0
- {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/api/reports.py +0 -0
- {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/api/search.py +0 -0
- {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/api/sessions.py +0 -0
- {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/api/subscription.py +0 -0
- {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/api/users.py +0 -0
- {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/api/verification.py +0 -0
- {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/clan.py +0 -0
- {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/exceptions.py +0 -0
- {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/file.py +0 -0
- {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/hashtag.py +0 -0
- {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/logger.py +0 -0
- {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/pin.py +0 -0
- {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/poll.py +0 -0
- {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/report.py +0 -0
- {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/request.py +0 -0
- {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/session.py +0 -0
- {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/span.py +0 -0
- {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/utils.py +0 -0
- {itd_sdk-2.5.2 → itd_sdk-2.6.0}/itd/version.py +0 -0
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
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,
|
|
45
|
-
return client.request('post', f'comments/{
|
|
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,
|
|
50
|
-
return client.request('delete', f'comments/{
|
|
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,
|
|
55
|
-
return client.request('delete', f'comments/{
|
|
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,
|
|
60
|
-
return client.request('get', f'comments/{
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
|
341
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
82
|
-
limit_coefficient: float | None = None
|
|
83
|
-
auto_acquire: bool =
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 |
|
|
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
|
itd_sdk-2.5.2/itd/_limiter.py
DELETED
|
@@ -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
|