itd-sdk 2.5.2__tar.gz → 2.6.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {itd_sdk-2.5.2 → itd_sdk-2.6.1}/PKG-INFO +2 -2
- {itd_sdk-2.5.2 → itd_sdk-2.6.1}/README.md +1 -1
- {itd_sdk-2.5.2 → itd_sdk-2.6.1}/itd/__init__.py +12 -6
- {itd_sdk-2.5.2 → itd_sdk-2.6.1}/itd/_default.py +22 -2
- itd_sdk-2.6.1/itd/_limiter.py +96 -0
- {itd_sdk-2.5.2 → itd_sdk-2.6.1}/itd/api/comments.py +14 -9
- itd_sdk-2.6.1/itd/api/notifications.py +62 -0
- {itd_sdk-2.5.2 → itd_sdk-2.6.1}/itd/api/posts.py +0 -5
- {itd_sdk-2.5.2 → itd_sdk-2.6.1}/itd/base.py +72 -23
- {itd_sdk-2.5.2 → itd_sdk-2.6.1}/itd/client.py +91 -42
- {itd_sdk-2.5.2 → itd_sdk-2.6.1}/itd/comment.py +88 -41
- {itd_sdk-2.5.2 → itd_sdk-2.6.1}/itd/enums.py +12 -2
- {itd_sdk-2.5.2 → itd_sdk-2.6.1}/itd/hashtag.py +4 -5
- {itd_sdk-2.5.2 → itd_sdk-2.6.1}/itd/notification.py +144 -18
- {itd_sdk-2.5.2 → itd_sdk-2.6.1}/itd/poll.py +7 -20
- {itd_sdk-2.5.2 → itd_sdk-2.6.1}/itd/post.py +85 -78
- {itd_sdk-2.5.2 → itd_sdk-2.6.1}/itd/request.py +2 -2
- {itd_sdk-2.5.2 → itd_sdk-2.6.1}/itd/user.py +54 -50
- {itd_sdk-2.5.2 → itd_sdk-2.6.1}/itd/utils.py +1 -1
- {itd_sdk-2.5.2 → itd_sdk-2.6.1}/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.1}/itd/api/__init__.py +0 -0
- {itd_sdk-2.5.2 → itd_sdk-2.6.1}/itd/api/auth.py +0 -0
- {itd_sdk-2.5.2 → itd_sdk-2.6.1}/itd/api/dwell.py +0 -0
- {itd_sdk-2.5.2 → itd_sdk-2.6.1}/itd/api/etc.py +0 -0
- {itd_sdk-2.5.2 → itd_sdk-2.6.1}/itd/api/files.py +0 -0
- {itd_sdk-2.5.2 → itd_sdk-2.6.1}/itd/api/hashtags.py +0 -0
- {itd_sdk-2.5.2 → itd_sdk-2.6.1}/itd/api/pins.py +0 -0
- {itd_sdk-2.5.2 → itd_sdk-2.6.1}/itd/api/platform.py +0 -0
- {itd_sdk-2.5.2 → itd_sdk-2.6.1}/itd/api/polls.py +0 -0
- {itd_sdk-2.5.2 → itd_sdk-2.6.1}/itd/api/reports.py +0 -0
- {itd_sdk-2.5.2 → itd_sdk-2.6.1}/itd/api/search.py +0 -0
- {itd_sdk-2.5.2 → itd_sdk-2.6.1}/itd/api/sessions.py +0 -0
- {itd_sdk-2.5.2 → itd_sdk-2.6.1}/itd/api/subscription.py +0 -0
- {itd_sdk-2.5.2 → itd_sdk-2.6.1}/itd/api/users.py +0 -0
- {itd_sdk-2.5.2 → itd_sdk-2.6.1}/itd/api/verification.py +0 -0
- {itd_sdk-2.5.2 → itd_sdk-2.6.1}/itd/clan.py +0 -0
- {itd_sdk-2.5.2 → itd_sdk-2.6.1}/itd/exceptions.py +0 -0
- {itd_sdk-2.5.2 → itd_sdk-2.6.1}/itd/file.py +0 -0
- {itd_sdk-2.5.2 → itd_sdk-2.6.1}/itd/logger.py +0 -0
- {itd_sdk-2.5.2 → itd_sdk-2.6.1}/itd/pin.py +0 -0
- {itd_sdk-2.5.2 → itd_sdk-2.6.1}/itd/report.py +0 -0
- {itd_sdk-2.5.2 → itd_sdk-2.6.1}/itd/session.py +0 -0
- {itd_sdk-2.5.2 → itd_sdk-2.6.1}/itd/span.py +0 -0
- {itd_sdk-2.5.2 → itd_sdk-2.6.1}/itd/version.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: itd-sdk
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.6.1
|
|
4
4
|
Summary: ITD client for python
|
|
5
5
|
Keywords: itd,itdsocial
|
|
6
6
|
Author: fi-res
|
|
@@ -29,7 +29,7 @@ Description-Content-Type: text/markdown
|
|
|
29
29
|
|
|
30
30
|
# itd-sdk
|
|
31
31
|
|
|
32
|
-
[](https://github.com/fi-res/ncruff)
|
|
32
|
+
[](https://github.com/fi-res/ncruff) [](https://jacobtomlinson.dev/effver)
|
|
33
33
|
|
|
34
34
|
SDK для работы с [https://xn--d1ah4a.com](итд.com) на python.
|
|
35
35
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# itd-sdk
|
|
2
2
|
|
|
3
|
-
[](https://github.com/fi-res/ncruff)
|
|
3
|
+
[](https://github.com/fi-res/ncruff) [](https://jacobtomlinson.dev/effver)
|
|
4
4
|
|
|
5
5
|
SDK для работы с [https://xn--d1ah4a.com](итд.com) на python.
|
|
6
6
|
|
|
@@ -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
|
-
@api_wrapper(NotFoundError('Comment'), AlreadyDeletedError('Comment'))
|
|
54
|
-
def delete_comment(client: Client,
|
|
55
|
-
return client.request('delete', f'comments/{
|
|
53
|
+
@api_wrapper(NotFoundError('Comment'), AlreadyDeletedError('Comment'), ForbiddenError('delete comment'))
|
|
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})
|
|
@@ -4,13 +4,14 @@ from abc import abstractmethod
|
|
|
4
4
|
from functools import wraps
|
|
5
5
|
from time import sleep
|
|
6
6
|
from typing import TYPE_CHECKING, Any, Callable, Iterator, SupportsIndex, TypeVar, cast, overload
|
|
7
|
+
from uuid import UUID
|
|
7
8
|
|
|
8
9
|
from pydantic import BaseModel
|
|
10
|
+
from pydantic.fields import FieldInfo
|
|
9
11
|
from requests import Response
|
|
10
12
|
from requests.exceptions import JSONDecodeError
|
|
11
13
|
|
|
12
|
-
from itd._default import
|
|
13
|
-
from itd._limiter import BurstRateLimiter, SafeRateLimiter
|
|
14
|
+
from itd._default import get_config, get_default_client, limiters, limits
|
|
14
15
|
from itd.enums import ALL, BATCH, All, Batch, DebugResponseMode, LoadStatus
|
|
15
16
|
from itd.exceptions import DEFAULT_ERRORS, AccessTokenExpiredError, AccountDeletedError, ITDException, RateLimitError, UnauthorizedError, ValidationError
|
|
16
17
|
from itd.logger import get_logger
|
|
@@ -50,7 +51,9 @@ class ITDBaseModel:
|
|
|
50
51
|
|
|
51
52
|
def __init__(self, client: Client | None = None) -> None:
|
|
52
53
|
self._client = client or get_default_client()
|
|
54
|
+
|
|
53
55
|
self._loaded_attrs: set[str] = set()
|
|
56
|
+
self._extra_context = {}
|
|
54
57
|
|
|
55
58
|
def __setattr__(self, name: str, value: Any) -> None:
|
|
56
59
|
if isinstance(value, ITDBaseModel) and (client := _getattr(self, '_client')): # ai
|
|
@@ -72,32 +75,33 @@ class ITDBaseModel:
|
|
|
72
75
|
if not self._refreshable:
|
|
73
76
|
l.warning(f'{self.__class__.__name__} is not refreshable but refresh is called')
|
|
74
77
|
self.load_status = LoadStatus.LOADING
|
|
75
|
-
self._fill_from_data(self._refresh(client=client or self.client))
|
|
78
|
+
self._fill_from_data(self._refresh(client=client or self.client), context={'client': client or self.client})
|
|
76
79
|
self.load_status = LoadStatus.FULL
|
|
77
80
|
return self
|
|
78
81
|
|
|
79
|
-
def _fill_from_data(self, data: dict):
|
|
82
|
+
def _fill_from_data(self, data: dict, *, context: dict = {}):
|
|
80
83
|
assert self._validator, 'Unable to use fill_from_data without a validator'
|
|
81
|
-
|
|
84
|
+
context.update(self._extra_context)
|
|
85
|
+
validated = self._validator().model_validate(data, context=context) # ty: ignore[missing-argument]
|
|
82
86
|
self._loaded_attrs = validated.model_fields_set # значения автоматом добавляются через setattr # так значит это же тогда надо закоментить? # хз наверн
|
|
83
87
|
for name, value in validated.__dict__.items():
|
|
84
|
-
|
|
88
|
+
object.__setattr__(self, name, value)
|
|
85
89
|
|
|
86
90
|
self._post_refresh()
|
|
87
91
|
|
|
88
92
|
@classmethod
|
|
89
|
-
def from_dict(cls, data: dict, *, client: Client | None = None):
|
|
93
|
+
def from_dict(cls, data: dict, *, context: dict = {}, client: Client | None = None):
|
|
90
94
|
instance = cls.__new__(cls)
|
|
91
95
|
ITDBaseModel.__init__(instance, client)
|
|
92
96
|
|
|
93
|
-
instance.
|
|
97
|
+
context.setdefault('client', client or instance.client)
|
|
98
|
+
instance._fill_from_data(data, context=context)
|
|
94
99
|
instance.load_status = LoadStatus.PARTIALLY
|
|
95
100
|
return instance
|
|
96
101
|
|
|
97
102
|
if not TYPE_CHECKING:
|
|
98
103
|
|
|
99
104
|
def __getattribute__(self, name: str) -> Any:
|
|
100
|
-
|
|
101
105
|
try:
|
|
102
106
|
value = object.__getattribute__(self, name)
|
|
103
107
|
exc = None
|
|
@@ -111,14 +115,38 @@ class ITDBaseModel:
|
|
|
111
115
|
and name not in ('client', 'model_fields_set', 'load_status')
|
|
112
116
|
and not callable(value)
|
|
113
117
|
and not isinstance(_getattr(type(self), name), property)
|
|
114
|
-
and name not in _getattr(self, '_loaded_attrs')
|
|
115
118
|
and _getattr(self, 'load_status') in (LoadStatus.NO, LoadStatus.PARTIALLY)
|
|
119
|
+
and name not in (_getattr(self, '_loaded_attrs') or {name})
|
|
120
|
+
and self.client.config.load_on_getattr
|
|
116
121
|
and not (isinstance(value, ITDBaseModel) and not value._load_with_parent)
|
|
117
122
|
):
|
|
118
123
|
l.info('refresh %s field=%s load_status=%s', self.__class__.__name__, name, _getattr(self, 'load_status').value)
|
|
119
124
|
self.refresh()
|
|
120
125
|
return object.__getattribute__(self, name)
|
|
121
126
|
|
|
127
|
+
annotations = {}
|
|
128
|
+
[annotations.update(_getattr(c, '__annotations__')) for c in _getattr(self, '__class__').__mro__ if _getattr(c, '__annotations__')]
|
|
129
|
+
|
|
130
|
+
if isinstance(value, FieldInfo) or (not name.startswith('_') and exc is not None and not _getattr(self, 'client').config.load_on_getattr):
|
|
131
|
+
value_type = annotations[name]
|
|
132
|
+
if 'None' in value_type:
|
|
133
|
+
l.warning('returned fake value None for %s: please load data (call refresh()) before get attributes', name)
|
|
134
|
+
return None
|
|
135
|
+
elif value_type == 'int':
|
|
136
|
+
l.warning('returned fake value 0 for %s: please load data (call refresh()) before get attributes', name)
|
|
137
|
+
return 0
|
|
138
|
+
elif value_type == 'UUID':
|
|
139
|
+
l.warning('returned fake value 00000000-0000-0000-0000-000000000010 for %s: please load data (call refresh()) before get attributes', name)
|
|
140
|
+
return UUID('00000000-0000-0000-0000-000000000000')
|
|
141
|
+
elif value_type == 'str':
|
|
142
|
+
l.warning('returned fake value "" for %s: please load data (call refresh()) before get attributes', name)
|
|
143
|
+
return ''
|
|
144
|
+
elif value_type == 'bool':
|
|
145
|
+
l.warning('returned fake value False for %s: please load data (call refresh()) before get attributes', name)
|
|
146
|
+
return False # дилема
|
|
147
|
+
l.error('cannot fake data for %s (type %s): please load data (call refresh()) before get attributes', name, value_type)
|
|
148
|
+
raise RuntimeError(f'cannot fake data for {name} (type {value_type}): please load data (call refresh()) before get attributes')
|
|
149
|
+
|
|
122
150
|
if exc is not None:
|
|
123
151
|
raise exc
|
|
124
152
|
return value
|
|
@@ -134,6 +162,7 @@ class ITDList(ITDBaseModel, list[T]):
|
|
|
134
162
|
_refreshable = False
|
|
135
163
|
has_more = True
|
|
136
164
|
idx = 0
|
|
165
|
+
_is_page_pagination: bool = False
|
|
137
166
|
|
|
138
167
|
def _fetch(self, client: Client, limit: int) -> dict:
|
|
139
168
|
return {}
|
|
@@ -155,9 +184,15 @@ class ITDList(ITDBaseModel, list[T]):
|
|
|
155
184
|
l.warning('skip load because has_more=False')
|
|
156
185
|
return []
|
|
157
186
|
|
|
158
|
-
|
|
187
|
+
default_limit = (client or self.client).config.batch_sizes._values[self.__class__.__name__.lower()]
|
|
188
|
+
limit = limit or default_limit
|
|
159
189
|
if isinstance(count, int) and count < limit:
|
|
160
190
|
limit = count
|
|
191
|
+
|
|
192
|
+
if self._is_page_pagination and limit != default_limit:
|
|
193
|
+
l.warning('unable to change limit in %s (page pagination), using default %s', self.__class__.__name__.lower(), default_limit)
|
|
194
|
+
count = limit = default_limit
|
|
195
|
+
|
|
161
196
|
l.debug('load %s count=%s limit=%s cursor=%s', self.__class__.__name__.lower(), count, limit, self.cursor)
|
|
162
197
|
|
|
163
198
|
# Batch = load one batch (limit), All = load everything, int = load exactly N
|
|
@@ -263,7 +298,7 @@ class ITDList(ITDBaseModel, list[T]):
|
|
|
263
298
|
if value is None or isinstance(self.client.config.load_on_getitem, All):
|
|
264
299
|
l.debug('getitem load all')
|
|
265
300
|
self.load_all()
|
|
266
|
-
elif isinstance(self.client.config.load_on_getitem, Batch):
|
|
301
|
+
elif self._is_page_pagination or isinstance(self.client.config.load_on_getitem, Batch):
|
|
267
302
|
l.debug('getitem load batch')
|
|
268
303
|
self.load(BATCH)
|
|
269
304
|
else:
|
|
@@ -277,7 +312,7 @@ class ITDList(ITDBaseModel, list[T]):
|
|
|
277
312
|
if getattr(self, 'total', None) and self.idx >= self.total:
|
|
278
313
|
raise StopIteration()
|
|
279
314
|
if self.idx >= len(self) and (self.has_more or self.client.config.force_load_lists):
|
|
280
|
-
l.debug('not enough items to call next
|
|
315
|
+
l.debug('not enough items to call next, load')
|
|
281
316
|
self.load(self.client.config.load_on_iter)
|
|
282
317
|
if self.idx >= len(self):
|
|
283
318
|
raise StopIteration()
|
|
@@ -295,6 +330,11 @@ class ITDList(ITDBaseModel, list[T]):
|
|
|
295
330
|
def all(self) -> list[T]:
|
|
296
331
|
return self.load_all()
|
|
297
332
|
|
|
333
|
+
def clear(self):
|
|
334
|
+
super().clear()
|
|
335
|
+
self.cursor = None
|
|
336
|
+
self.has_more = True
|
|
337
|
+
|
|
298
338
|
|
|
299
339
|
def _filter_bytes(args: tuple):
|
|
300
340
|
filtered = []
|
|
@@ -306,7 +346,7 @@ def _filter_bytes(args: tuple):
|
|
|
306
346
|
return filtered
|
|
307
347
|
|
|
308
348
|
|
|
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
|
|
349
|
+
# 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
350
|
def api_wrapper(*exceptions: ITDException):
|
|
311
351
|
"""Декоратор для отлавливания ошибок
|
|
312
352
|
|
|
@@ -321,9 +361,19 @@ def api_wrapper(*exceptions: ITDException):
|
|
|
321
361
|
|
|
322
362
|
def exec():
|
|
323
363
|
l.info('exec %s %s %s', func.__name__, _filter_bytes(args), kwargs)
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
364
|
+
|
|
365
|
+
config = get_config()
|
|
366
|
+
is_first = True
|
|
367
|
+
if name in limits and limits[name] in limiters:
|
|
368
|
+
limiter = limiters[limits[name]]
|
|
369
|
+
if config.auto_acquire:
|
|
370
|
+
limiter.acquire()
|
|
371
|
+
is_first = not limiter.used
|
|
372
|
+
limiter.request()
|
|
373
|
+
|
|
374
|
+
ip_limiter = config.ip_limiter
|
|
375
|
+
if ip_limiter:
|
|
376
|
+
ip_limiter.acquire()
|
|
327
377
|
|
|
328
378
|
res: Response = func(client, *args, **kwargs)
|
|
329
379
|
|
|
@@ -337,12 +387,10 @@ def api_wrapper(*exceptions: ITDException):
|
|
|
337
387
|
limit = int(res.headers.get('x-ratelimit-limit', 0))
|
|
338
388
|
limits[name] = limit
|
|
339
389
|
|
|
340
|
-
if
|
|
341
|
-
|
|
342
|
-
limiters[limit] = BurstRateLimiter(limit)
|
|
390
|
+
if limit not in limiters and config.limiter is not None:
|
|
391
|
+
limiters[limit] = config.limiter(limit)
|
|
343
392
|
|
|
344
|
-
|
|
345
|
-
limiters[limit] = SafeRateLimiter(limit)
|
|
393
|
+
if limit in limiters and (config.auto_acquire or is_first):
|
|
346
394
|
limiters[limit].sync(remaining)
|
|
347
395
|
|
|
348
396
|
if client.config.debug_response == DebugResponseMode.BEFORE:
|
|
@@ -409,7 +457,8 @@ def api_wrapper(*exceptions: ITDException):
|
|
|
409
457
|
retry_after = getattr(e, 'retry_after') or client.config.retry_delay
|
|
410
458
|
l.warning('%s on %s: wait %ss', e.__class__.__name__, func.__name__, retry_after)
|
|
411
459
|
sleep(retry_after)
|
|
412
|
-
|
|
460
|
+
if name in limits and limits[name] in limiters:
|
|
461
|
+
limiters[limits[name]].on_limit()
|
|
413
462
|
|
|
414
463
|
return wrapper
|
|
415
464
|
|