itd-sdk 2.2.0__tar.gz → 2.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {itd_sdk-2.2.0 → itd_sdk-2.4.0}/PKG-INFO +5 -4
- {itd_sdk-2.2.0 → itd_sdk-2.4.0}/README.md +2 -2
- {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/__init__.py +2 -0
- {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/api/auth.py +13 -1
- itd_sdk-2.4.0/itd/api/dwell.py +19 -0
- {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/api/notifications.py +1 -1
- itd_sdk-2.4.0/itd/api/platform.py +17 -0
- {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/api/posts.py +5 -0
- itd_sdk-2.4.0/itd/api/sessions.py +23 -0
- {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/base.py +70 -43
- itd_sdk-2.4.0/itd/clan.py +38 -0
- {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/client.py +95 -24
- {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/comment.py +9 -1
- {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/enums.py +34 -1
- {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/exceptions.py +34 -3
- itd_sdk-2.4.0/itd/file.py +170 -0
- {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/hashtag.py +10 -0
- {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/logger.py +9 -11
- {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/notification.py +62 -24
- {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/post.py +267 -38
- {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/request.py +3 -3
- itd_sdk-2.4.0/itd/session.py +79 -0
- {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/user.py +37 -12
- {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/utils.py +12 -2
- itd_sdk-2.4.0/itd/version.py +73 -0
- {itd_sdk-2.2.0 → itd_sdk-2.4.0}/pyproject.toml +58 -56
- itd_sdk-2.2.0/itd/clan.py +0 -17
- itd_sdk-2.2.0/itd/file.py +0 -94
- {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/_default.py +0 -0
- {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/api/__init__.py +0 -0
- {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/api/comments.py +0 -0
- {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/api/etc.py +0 -0
- {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/api/files.py +0 -0
- {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/api/hashtags.py +0 -0
- {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/api/pins.py +0 -0
- {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/api/polls.py +0 -0
- {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/api/reports.py +0 -0
- {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/api/search.py +0 -0
- {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/api/subscription.py +0 -0
- {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/api/users.py +0 -0
- {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/api/verification.py +0 -0
- {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/pin.py +0 -0
- {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/poll.py +0 -0
- {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/report.py +0 -0
- {itd_sdk-2.2.0 → itd_sdk-2.4.0}/itd/span.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: itd-sdk
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.4.0
|
|
4
4
|
Summary: ITD client for python
|
|
5
5
|
Keywords: itd,itdsocial
|
|
6
6
|
Author: firedotguy
|
|
@@ -11,22 +11,23 @@ Classifier: Natural Language :: Russian
|
|
|
11
11
|
Classifier: Programming Language :: Python
|
|
12
12
|
Classifier: Programming Language :: Python :: 3
|
|
13
13
|
Classifier: Framework :: Pydantic :: 2
|
|
14
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
15
14
|
Requires-Dist: requests~=2.32.5
|
|
16
15
|
Requires-Dist: pydantic>=2.12.5,<2.14.0
|
|
17
16
|
Requires-Dist: sseclient-py~=1.9.0
|
|
17
|
+
Requires-Dist: dateparser>=1.2.2 ; extra == 'date'
|
|
18
18
|
Requires-Dist: filetype>=1.2.0 ; extra == 'filetype'
|
|
19
19
|
Requires-Dist: rich>=15.0.0 ; extra == 'logs'
|
|
20
20
|
Requires-Python: >=3.9
|
|
21
21
|
Project-URL: Homepage, https://itdsdk.qzz.io
|
|
22
22
|
Project-URL: Documentation, https://itdsdk.qzz.io/docs
|
|
23
|
+
Provides-Extra: date
|
|
23
24
|
Provides-Extra: filetype
|
|
24
25
|
Provides-Extra: logs
|
|
25
26
|
Description-Content-Type: text/markdown
|
|
26
27
|
|
|
27
28
|
# itd-sdk
|
|
28
29
|
Клиент ITD для python
|
|
29
|
-
Документация (beta): https://
|
|
30
|
+
Документация (beta): https://itdsdk.qzz.io/docs
|
|
30
31
|
|
|
31
32
|
## Установка
|
|
32
33
|
|
|
@@ -124,7 +125,7 @@ fetch(c, 'метод', 'эндпоинт', {'данные': 'данные'})
|
|
|
124
125
|
## Прочее
|
|
125
126
|
- Лицезия: [MIT](./LICENSE)
|
|
126
127
|
- Автор:
|
|
127
|
-
- ИТД: [itd_sdk](https://xn--d1ah4a.com/@itd_sdk) или [@fdg](https://xn--d1ah4a.com/@pingbot)
|
|
128
|
+
- ИТД: [@itd_sdk](https://xn--d1ah4a.com/@itd_sdk) или [@fdg](https://xn--d1ah4a.com/@pingbot)
|
|
128
129
|
- ТГ: [@desicars](https://t.me/desicars)
|
|
129
130
|
|
|
130
131
|
[](https://www.star-history.com/?repos=itd-sdk%2Fitd-sdk&type=date&legend=top-left)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# itd-sdk
|
|
2
2
|
Клиент ITD для python
|
|
3
|
-
Документация (beta): https://
|
|
3
|
+
Документация (beta): https://itdsdk.qzz.io/docs
|
|
4
4
|
|
|
5
5
|
## Установка
|
|
6
6
|
|
|
@@ -98,7 +98,7 @@ fetch(c, 'метод', 'эндпоинт', {'данные': 'данные'})
|
|
|
98
98
|
## Прочее
|
|
99
99
|
- Лицезия: [MIT](./LICENSE)
|
|
100
100
|
- Автор:
|
|
101
|
-
- ИТД: [itd_sdk](https://xn--d1ah4a.com/@itd_sdk) или [@fdg](https://xn--d1ah4a.com/@pingbot)
|
|
101
|
+
- ИТД: [@itd_sdk](https://xn--d1ah4a.com/@itd_sdk) или [@fdg](https://xn--d1ah4a.com/@pingbot)
|
|
102
102
|
- ТГ: [@desicars](https://t.me/desicars)
|
|
103
103
|
|
|
104
104
|
[](https://www.star-history.com/?repos=itd-sdk%2Fitd-sdk&type=date&legend=top-left)
|
|
@@ -2,6 +2,7 @@ from importlib.metadata import version
|
|
|
2
2
|
|
|
3
3
|
__version__ = version("itd-sdk")
|
|
4
4
|
|
|
5
|
+
from itd.version import Changelog, Apps
|
|
5
6
|
from itd.client import Client as ITDClient, Config as ITDConfig
|
|
6
7
|
from itd.clan import Clan, TopClans
|
|
7
8
|
from itd.file import File
|
|
@@ -9,4 +10,5 @@ from itd.hashtag import Hashtag, Hashtags
|
|
|
9
10
|
from itd.notification import Notifications
|
|
10
11
|
from itd.post import Post, Posts, UserPosts, HashtagPosts, LikedPosts
|
|
11
12
|
from itd.poll import NewPoll
|
|
13
|
+
from itd.session import Sessions
|
|
12
14
|
from itd.user import User, Me
|
|
@@ -5,7 +5,11 @@ from requests import Response
|
|
|
5
5
|
|
|
6
6
|
from itd.base import catch_errors, rate_limit
|
|
7
7
|
from itd.enums import AuthLevel
|
|
8
|
-
from itd.exceptions import
|
|
8
|
+
from itd.exceptions import (
|
|
9
|
+
InvalidPasswordError, SamePasswordError, InvalidOldPasswordError, SessionNotFoundError,
|
|
10
|
+
SessionExpiredError, SessionRevokedError, InvalidCredentials, CaptchaFailedError,
|
|
11
|
+
EmailDomainNotAllowed
|
|
12
|
+
)
|
|
9
13
|
if TYPE_CHECKING:
|
|
10
14
|
from itd.client import Client
|
|
11
15
|
|
|
@@ -14,10 +18,18 @@ if TYPE_CHECKING:
|
|
|
14
18
|
def refresh_token(client: Client) -> Response:
|
|
15
19
|
return client.request('post', 'v1/auth/refresh', level=AuthLevel.REFRESH)
|
|
16
20
|
|
|
21
|
+
@rate_limit()
|
|
17
22
|
@catch_errors(InvalidPasswordError(), SamePasswordError(), InvalidOldPasswordError())
|
|
18
23
|
def change_password(client: Client, old: str, new: str) -> Response:
|
|
19
24
|
return client.request('post', 'v1/auth/change-password', {'newPassword': new, 'oldPassword': old})
|
|
20
25
|
|
|
26
|
+
@rate_limit()
|
|
21
27
|
@catch_errors()
|
|
22
28
|
def logout(client: Client) -> Response:
|
|
23
29
|
return client.request('post', 'v1/auth/logout', level=AuthLevel.REFRESH)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@rate_limit()
|
|
33
|
+
@catch_errors(InvalidCredentials(), CaptchaFailedError(), EmailDomainNotAllowed())
|
|
34
|
+
def sign_in(client: Client, email: str, password: str, turnstile: str):
|
|
35
|
+
return client.request('post', 'v1/auth/sign-in', {'email': email, 'password': password, 'turnstileToken': turnstile}, level=AuthLevel.NO)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import TYPE_CHECKING
|
|
3
|
+
from uuid import UUID
|
|
4
|
+
|
|
5
|
+
from itd.base import catch_errors, rate_limit
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from itd.client import Client
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@rate_limit()
|
|
11
|
+
@catch_errors()
|
|
12
|
+
def send_views(client: Client, objects: list[dict], sid: UUID):
|
|
13
|
+
return client.request('post', 'v1/i', {'e': objects, 'sid': str(sid)})
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@rate_limit()
|
|
17
|
+
@catch_errors()
|
|
18
|
+
def send_interactions(client: Client, objects: list[dict], sid: UUID):
|
|
19
|
+
return client.request('post', 'v1/x', {'e': objects, 'sid': str(sid)})
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import TYPE_CHECKING
|
|
3
|
+
|
|
4
|
+
if TYPE_CHECKING:
|
|
5
|
+
from itd.client import Client
|
|
6
|
+
from itd.enums import AuthLevel
|
|
7
|
+
from itd.base import catch_errors, rate_limit
|
|
8
|
+
|
|
9
|
+
@catch_errors()
|
|
10
|
+
@rate_limit()
|
|
11
|
+
def get_apps(client: Client):
|
|
12
|
+
return client.request('get', 'platform/version', level=AuthLevel.NO)
|
|
13
|
+
|
|
14
|
+
@catch_errors()
|
|
15
|
+
@rate_limit()
|
|
16
|
+
def get_changelog(client: Client):
|
|
17
|
+
return client.request('get', 'platform/changelog', level=AuthLevel.NO)
|
|
@@ -105,3 +105,8 @@ def like_post(client: Client, id: UUID):
|
|
|
105
105
|
@catch_errors(NotFoundError('Post'))
|
|
106
106
|
def unlike_post(client: Client, id: UUID):
|
|
107
107
|
return client.request('delete', f'posts/{id}/like')
|
|
108
|
+
|
|
109
|
+
@rate_limit()
|
|
110
|
+
@catch_errors(ValidationError())
|
|
111
|
+
def get_stats(client: Client, ids: list[UUID]):
|
|
112
|
+
return client.request('post', 'posts/stats', {'ids': [str(id) for id in ids]}) # if not found, will return empty list
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import TYPE_CHECKING
|
|
3
|
+
from uuid import UUID
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from itd.client import Client
|
|
7
|
+
from itd.exceptions import ValidationError
|
|
8
|
+
from itd.base import catch_errors, rate_limit
|
|
9
|
+
|
|
10
|
+
@catch_errors()
|
|
11
|
+
@rate_limit()
|
|
12
|
+
def get_sessions(client: Client):
|
|
13
|
+
return client.request('get', 'v1/auth/sessions')
|
|
14
|
+
|
|
15
|
+
@catch_errors()
|
|
16
|
+
@rate_limit()
|
|
17
|
+
def delete_session(client: Client, id: UUID):
|
|
18
|
+
return client.request('delete', f'v1/auth/sessions/{id}')
|
|
19
|
+
|
|
20
|
+
@catch_errors()
|
|
21
|
+
@rate_limit()
|
|
22
|
+
def delete_all_sessions(client: Client):
|
|
23
|
+
return client.request('delete', 'v1/auth/sessions')
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
-
from typing import Any, Callable, TYPE_CHECKING, Iterator, TypeVar
|
|
2
|
+
from typing import Any, Callable, TYPE_CHECKING, Iterator, TypeVar, overload
|
|
3
3
|
from functools import wraps
|
|
4
4
|
from time import sleep
|
|
5
5
|
from datetime import datetime, timedelta
|
|
@@ -12,7 +12,10 @@ from pydantic_core import PydanticUndefinedType
|
|
|
12
12
|
|
|
13
13
|
from itd._default import get_default_client
|
|
14
14
|
from itd.logger import get_logger
|
|
15
|
-
from itd.exceptions import
|
|
15
|
+
from itd.exceptions import (
|
|
16
|
+
ITDException, ValidationError, RateLimitError, UnauthorizedError, AccessTokenExpiredError,
|
|
17
|
+
DEFAULT_ERRORS, AccountDeletedError
|
|
18
|
+
)
|
|
16
19
|
from itd.enums import All, ALL, DebugResponseMode, RateLimitMode, BATCH, Batch
|
|
17
20
|
if TYPE_CHECKING:
|
|
18
21
|
from itd.client import Client
|
|
@@ -20,6 +23,11 @@ if TYPE_CHECKING:
|
|
|
20
23
|
|
|
21
24
|
l = get_logger('base')
|
|
22
25
|
|
|
26
|
+
def _is_comments_on_post(value: Any, self: Any) -> bool:
|
|
27
|
+
from itd.comment import Comments
|
|
28
|
+
from itd.post import Post
|
|
29
|
+
return isinstance(value, Comments) and isinstance(self, Post)
|
|
30
|
+
|
|
23
31
|
def _getattr(self: object, name: str, default: Any | None = None) -> Any:
|
|
24
32
|
try:
|
|
25
33
|
return object.__getattribute__(self, name)
|
|
@@ -53,6 +61,9 @@ class ITDBaseModel:
|
|
|
53
61
|
value._client = client
|
|
54
62
|
object.__setattr__(self, name, value)
|
|
55
63
|
|
|
64
|
+
def _post_refresh(self):
|
|
65
|
+
pass
|
|
66
|
+
|
|
56
67
|
@property
|
|
57
68
|
def client(self) -> Client:
|
|
58
69
|
return self._client
|
|
@@ -64,35 +75,28 @@ class ITDBaseModel:
|
|
|
64
75
|
|
|
65
76
|
if not TYPE_CHECKING:
|
|
66
77
|
def __getattribute__(self, name: str) -> Any:
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
any(triggers.values()) and
|
|
85
|
-
_getattr(self, 'client').config.auto_load and
|
|
86
|
-
not (name == 'comments' and not _getattr(self, 'client').config.load_comments_from_post) # да я хотел сделать нормально, поставить проперти на comments и тд, но это херня кака ято крч просто нахардкожу
|
|
87
|
-
):
|
|
88
|
-
l.info('load %s.%s reason=%s', self.__class__.__name__.lower(), name,
|
|
89
|
-
next((k for k, v in triggers.items() if v))
|
|
90
|
-
)
|
|
91
|
-
self.refresh()
|
|
78
|
+
value = _getattr(self, name)
|
|
79
|
+
if (
|
|
80
|
+
name.startswith('_') or # приватный аттрибут
|
|
81
|
+
name in ('client', 'model_fields_set') or
|
|
82
|
+
not _getattr(self, '_refreshable') or # не рефрешабельная модель
|
|
83
|
+
not _getattr(self, 'client').config.auto_load or # отключено в конфиге
|
|
84
|
+
callable(value) or # функция
|
|
85
|
+
(not _getattr(self, 'client').config.load_comments_from_post and _is_comments_on_post(value, self)) or
|
|
86
|
+
(isinstance(value, ITDBaseModel) and not value._load_with_parent)
|
|
87
|
+
):
|
|
88
|
+
# l.debug('return %s as is', name)
|
|
89
|
+
return object.__getattribute__(self, name)
|
|
90
|
+
|
|
91
|
+
if isinstance(value, FieldInfo) or (isinstance(value, ITDBaseModel) and value._load_with_parent and not value._loaded) or (not _getattr(self, '_loaded') and value is None):
|
|
92
|
+
l.info('refresh %s (caused by %s)', self.__class__.__name__, name)
|
|
93
|
+
self.refresh()
|
|
94
|
+
return object.__getattribute__(self, name)
|
|
92
95
|
|
|
93
96
|
return object.__getattribute__(self, name)
|
|
94
97
|
|
|
95
98
|
|
|
99
|
+
|
|
96
100
|
T = TypeVar('T', bound=ITDBaseModel)
|
|
97
101
|
|
|
98
102
|
class ITDList[T](ITDBaseModel, list[T]):
|
|
@@ -208,18 +212,33 @@ class ITDList[T](ITDBaseModel, list[T]):
|
|
|
208
212
|
"""
|
|
209
213
|
return self.load(ALL, limit, client)
|
|
210
214
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
+
@overload
|
|
216
|
+
def __getitem__(self, index: int) -> T: ...
|
|
217
|
+
|
|
218
|
+
@overload
|
|
219
|
+
def __getitem__(self, index: slice) -> list[T]: ...
|
|
220
|
+
|
|
221
|
+
def __getitem__(self, index: int | slice) -> T | list[T]: # pyright: ignore[reportIncompatibleMethodOverride]
|
|
222
|
+
|
|
223
|
+
if isinstance(index, slice):
|
|
224
|
+
value: int | None = index.stop
|
|
225
|
+
else:
|
|
226
|
+
value = index
|
|
227
|
+
|
|
228
|
+
if ((value is not None and value > len(self) - 1) or value is None) and self.client.config.load_on_getitem is not None:
|
|
229
|
+
if value:
|
|
230
|
+
self._min_total = value + 1
|
|
231
|
+
|
|
232
|
+
if value is None or isinstance(self.client.config.load_on_getitem, All):
|
|
215
233
|
l.debug('getitem load all')
|
|
216
234
|
self.load_all()
|
|
217
235
|
elif isinstance(self.client.config.load_on_getitem, Batch):
|
|
218
236
|
l.debug('getitem load batch')
|
|
219
237
|
self.load(BATCH)
|
|
220
238
|
else:
|
|
221
|
-
l.debug('getitem load %s',
|
|
222
|
-
self.load(
|
|
239
|
+
l.debug('getitem load %s', value - len(self) + self.client.config.load_on_getitem)
|
|
240
|
+
self.load(value - len(self) + self.client.config.load_on_getitem)
|
|
241
|
+
|
|
223
242
|
return super().__getitem__(index)
|
|
224
243
|
|
|
225
244
|
def __next__(self) -> T:
|
|
@@ -258,12 +277,13 @@ def refresh_wrapper(func):
|
|
|
258
277
|
if self._validator:
|
|
259
278
|
validated = self._validator().model_validate(data)
|
|
260
279
|
self._fields_from_data = validated.model_fields_set
|
|
261
|
-
l.debug('
|
|
280
|
+
l.debug('fill %s', self.__class__.__name__)
|
|
262
281
|
for name, value in validated.__dict__.items():
|
|
263
282
|
setattr(self, name, value)
|
|
264
283
|
|
|
265
284
|
# self._loading = False
|
|
266
285
|
self._loaded = True
|
|
286
|
+
self._post_refresh()
|
|
267
287
|
|
|
268
288
|
return wrapper
|
|
269
289
|
|
|
@@ -292,6 +312,8 @@ def catch_errors(*exceptions: ITDException):
|
|
|
292
312
|
|
|
293
313
|
assert isinstance(res, Response)
|
|
294
314
|
if res.status_code == 204:
|
|
315
|
+
if client.config.debug_response != DebugResponseMode.NO:
|
|
316
|
+
l.debug('no response')
|
|
295
317
|
return res
|
|
296
318
|
|
|
297
319
|
if client.config.debug_response == DebugResponseMode.BEFORE:
|
|
@@ -317,16 +339,17 @@ def catch_errors(*exceptions: ITDException):
|
|
|
317
339
|
):
|
|
318
340
|
if isinstance(exception, ValidationError):
|
|
319
341
|
exception.text = json.get('error', {}).get('message', 'Failed validation')
|
|
342
|
+
|
|
320
343
|
if isinstance(exception, RateLimitError) and isinstance(json.get('error'), dict):
|
|
321
344
|
exception.retry_after = json.get('error', {}).get('retryAfter', 0)
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
client.refresh_auth()
|
|
326
|
-
finally:
|
|
327
|
-
client._refreshing = False
|
|
345
|
+
|
|
346
|
+
if isinstance(exception, (UnauthorizedError, AccessTokenExpiredError)) and client.refresh_token:
|
|
347
|
+
client.refresh_auth()
|
|
328
348
|
return wrapper(client, *args, **kwargs)
|
|
329
349
|
|
|
350
|
+
if isinstance(exception, AccountDeletedError):
|
|
351
|
+
exception.can_restore = json.get('error', {}).get('canRestore', True)
|
|
352
|
+
|
|
330
353
|
raise exception
|
|
331
354
|
|
|
332
355
|
if client.config.debug_response == DebugResponseMode.AFTER:
|
|
@@ -363,7 +386,7 @@ def rate_limit(delay_min: float | None = None, delay_mid: float | None = None, d
|
|
|
363
386
|
else:
|
|
364
387
|
delay = client.config._rate_limit_default
|
|
365
388
|
|
|
366
|
-
if datetime.now() - timedelta(seconds=delay) < client.last_actions.get(func.__name__, datetime(
|
|
389
|
+
if datetime.now() - timedelta(seconds=delay) < client.last_actions.get(func.__name__, datetime(1990, 1, 1)):
|
|
367
390
|
delay -= (datetime.now() - client.last_actions[func.__name__]).seconds
|
|
368
391
|
l.debug('anti rate limit on %s; wait %ss', func.__name__, delay)
|
|
369
392
|
sleep(max(delay, 0))
|
|
@@ -376,8 +399,12 @@ def rate_limit(delay_min: float | None = None, delay_mid: float | None = None, d
|
|
|
376
399
|
try:
|
|
377
400
|
return func(client, *args, **kwargs)
|
|
378
401
|
except (client.config._retry_exceptions) as e:
|
|
379
|
-
|
|
380
|
-
|
|
402
|
+
if getattr(e, 'retry_after', 0) > client.config.retry_max_retry_after:
|
|
403
|
+
l.error('too large rate limit')
|
|
404
|
+
raise
|
|
405
|
+
|
|
406
|
+
retry_after = getattr(e, 'retry_after', client.config.retry_delay)
|
|
407
|
+
l.warning('%s on %s: wait %ss', e.__class__.__name__, func.__name__, retry_after)
|
|
381
408
|
sleep(retry_after)
|
|
382
409
|
|
|
383
410
|
return wrapper
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from pydantic import BaseModel, Field
|
|
2
|
+
|
|
3
|
+
from itd.base import ITDBaseModel
|
|
4
|
+
from itd.logger import get_logger
|
|
5
|
+
from itd.api.etc import get_top_clans
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
l = get_logger('clan')
|
|
9
|
+
|
|
10
|
+
class Clan(BaseModel):
|
|
11
|
+
avatar: str
|
|
12
|
+
members_count: int = Field(0, alias='memberCount')
|
|
13
|
+
|
|
14
|
+
def __str__(self) -> str:
|
|
15
|
+
return self.avatar
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TopClans(ITDBaseModel, list[Clan]):
|
|
19
|
+
_refreshable = False
|
|
20
|
+
|
|
21
|
+
def __init__(self) -> None:
|
|
22
|
+
super().__init__()
|
|
23
|
+
self.load()
|
|
24
|
+
|
|
25
|
+
def load(self):
|
|
26
|
+
self.clear()
|
|
27
|
+
self.extend([Clan.model_validate(clan) for clan in get_top_clans(self.client).json()['clans']])
|
|
28
|
+
return self
|
|
29
|
+
|
|
30
|
+
def refresh(self):
|
|
31
|
+
l.warning('TopClans.refresh is deprecated and will be removed in 2.6.0. Pleas use TopClans.load')
|
|
32
|
+
self.load()
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def empty(cls):
|
|
36
|
+
instance = cls.__new__(cls)
|
|
37
|
+
super(TopClans, instance).__init__()
|
|
38
|
+
return instance
|
|
@@ -2,6 +2,10 @@ from uuid import UUID
|
|
|
2
2
|
from _io import BufferedReader
|
|
3
3
|
from datetime import datetime
|
|
4
4
|
from dataclasses import dataclass, field
|
|
5
|
+
from atexit import register
|
|
6
|
+
from time import sleep
|
|
7
|
+
from threading import Thread
|
|
8
|
+
from typing import overload
|
|
5
9
|
|
|
6
10
|
from requests import Session
|
|
7
11
|
from requests.utils import default_user_agent
|
|
@@ -9,13 +13,15 @@ from requests.adapters import HTTPAdapter
|
|
|
9
13
|
from requests.exceptions import RequestException
|
|
10
14
|
|
|
11
15
|
from itd._default import _default_client, set_default_client
|
|
12
|
-
from itd.exceptions import
|
|
16
|
+
from itd.exceptions import InsufficientAuthLevelError, RateLimitError, InternalError, NotFoundError
|
|
13
17
|
from itd.hashtag import Hashtag
|
|
14
|
-
from itd.request import fetch, decode_jwt_payload
|
|
18
|
+
from itd.request import fetch, decode_jwt_payload, fetch_stream
|
|
15
19
|
from itd.enums import RateLimitMode, All, DebugResponseMode, ParseMode, Batch, BATCH, UserAgent, AuthLevel
|
|
16
20
|
from itd.user import Me, User
|
|
21
|
+
from itd.post import DwellTracker, Post
|
|
17
22
|
from itd.api.auth import refresh_token, change_password, logout
|
|
18
23
|
from itd.api.search import search
|
|
24
|
+
from itd.api.posts import get_stats
|
|
19
25
|
from itd.api.users import get_follow_status
|
|
20
26
|
from itd.utils import to_uuid, get_sdk_user_agent
|
|
21
27
|
from itd.logger import get_logger
|
|
@@ -29,31 +35,53 @@ class Config:
|
|
|
29
35
|
rate_limit: RateLimitMode = RateLimitMode.MID
|
|
30
36
|
rate_limit_default: int | None = None # overrides ratelimit mode # rate limit for standard actions
|
|
31
37
|
rate_limit_actions: dict[str, float | int] = field(default_factory=lambda: {}) # overrides ratelimit mode # custom rate limits for specific actions (eg. {'add_comment': 10})
|
|
38
|
+
|
|
32
39
|
# is_logging_enabled: bool = True # TODO
|
|
33
40
|
# logging_level = 'DEBUG'
|
|
41
|
+
|
|
34
42
|
is_default: bool = False
|
|
43
|
+
|
|
35
44
|
userposts_add_pinned_post: bool = True
|
|
45
|
+
|
|
36
46
|
auto_load: bool = True
|
|
37
47
|
load_on_getitem: int | All | Batch | None = 1
|
|
38
48
|
load_on_iter: int | All | Batch | None = BATCH
|
|
39
49
|
force_load_lists: bool = False # load lists even if has_more is False
|
|
50
|
+
|
|
40
51
|
debug_response: DebugResponseMode = DebugResponseMode.NO
|
|
52
|
+
|
|
41
53
|
timeout: float = 30
|
|
42
54
|
timeout_file: float = 120
|
|
55
|
+
|
|
43
56
|
url: str = 'xn--d1ah4a.com'
|
|
44
57
|
url_api: str | None = None
|
|
45
58
|
user_agent: UserAgent | str = UserAgent.BROWSER
|
|
46
59
|
solve_challenge: bool = True
|
|
60
|
+
|
|
47
61
|
load_comments_from_post: bool = False
|
|
62
|
+
|
|
48
63
|
parse_mode: ParseMode = ParseMode.NO
|
|
49
|
-
|
|
50
|
-
retry_on_rate_limits: bool | None = None # DEPRECATED
|
|
64
|
+
|
|
51
65
|
retry_enabled: bool = True
|
|
52
66
|
retry_delay: float = 10 # delay before next attempt (after rate limit error) if retry_after is not provided in request
|
|
53
67
|
retry_max_retries: int | None = None # none for no limit
|
|
54
68
|
retry_exceptions: tuple[type[Exception]] | list[type[Exception]] | None = None
|
|
69
|
+
retry_max_retry_after: int = 500
|
|
70
|
+
|
|
55
71
|
bypass_auth_level: bool = False
|
|
56
72
|
|
|
73
|
+
dwell_enabled: bool = True
|
|
74
|
+
dwell_max_buffer: int = 20
|
|
75
|
+
dwell_send_interval: float = 2
|
|
76
|
+
dwell_save_on_quit: bool = True
|
|
77
|
+
dwell_wait_durations: bool = True
|
|
78
|
+
|
|
79
|
+
post_update_stats: bool = False
|
|
80
|
+
post_update_stats_interval: int = 3
|
|
81
|
+
|
|
82
|
+
view_read_speed: int = 250 # in WPM # https://scholarwithin.com/average-reading-speed
|
|
83
|
+
view_images_speed: int = 130 # https://news.mit.edu/2014/in-the-blink-of-an-eye-0116
|
|
84
|
+
|
|
57
85
|
def __post_init__(self):
|
|
58
86
|
if self.rate_limit_default:
|
|
59
87
|
self._rate_limit_default = self.rate_limit_default
|
|
@@ -79,28 +107,21 @@ class Config:
|
|
|
79
107
|
case _:
|
|
80
108
|
self._user_agent = self.user_agent
|
|
81
109
|
|
|
82
|
-
if self.rate_limit_wait is not None:
|
|
83
|
-
l.warning('config.rate_limit_wait is deprecated and will be removed in 2.4.0. Please use config.retry_delay')
|
|
84
|
-
self.retry_delay = self.rate_limit_wait
|
|
85
|
-
if self.retry_on_rate_limits is not None:
|
|
86
|
-
l.warning('config.retry_on_rate_limits is deprecated and will be removed in 2.4.0. Please use config.retry_enabled')
|
|
87
|
-
self.retry_enabled = self.retry_on_rate_limits
|
|
88
110
|
|
|
89
111
|
self._retry_exceptions = (tuple(self.retry_exceptions) if isinstance(self.retry_exceptions, list) else self.retry_exceptions) or (RateLimitError, InternalError, RequestException)
|
|
90
112
|
|
|
91
113
|
|
|
92
114
|
|
|
93
115
|
class Client:
|
|
94
|
-
auth_level: AuthLevel = AuthLevel.NO
|
|
95
|
-
access_token: str | None = None
|
|
96
|
-
refresh_token: str | None = None
|
|
97
|
-
_user = None
|
|
98
|
-
_refreshing: bool = False
|
|
99
|
-
|
|
100
116
|
def __init__(self, refresh: str | None = None, access: str | None = None, config: Config = Config()):
|
|
101
117
|
l.info('init client refresh=%s access=%s', refresh is not None, access is not None)
|
|
102
118
|
self.config = config
|
|
103
119
|
self.last_actions: dict[str, datetime] = {}
|
|
120
|
+
self.auth_level: AuthLevel = AuthLevel.NO
|
|
121
|
+
self.access_token: str | None = None
|
|
122
|
+
self.refresh_token: str | None = None
|
|
123
|
+
self._user: Me | None = None
|
|
124
|
+
self.visible_posts: list[Post] = []
|
|
104
125
|
|
|
105
126
|
self.session = Session()
|
|
106
127
|
adapter = HTTPAdapter(pool_connections=1, pool_maxsize=10, pool_block=False) # idk what is this, (claude added) just for better stability
|
|
@@ -120,6 +141,38 @@ class Client:
|
|
|
120
141
|
if _default_client is None or config.is_default:
|
|
121
142
|
set_default_client(self)
|
|
122
143
|
|
|
144
|
+
if self.auth_level > AuthLevel.NO and self.config.dwell_enabled:
|
|
145
|
+
self.dwell_tracker = DwellTracker(self)
|
|
146
|
+
self.dwell_tracker._start_timer()
|
|
147
|
+
else:
|
|
148
|
+
self.dwell_tracker = None
|
|
149
|
+
|
|
150
|
+
if self.config.post_update_stats:
|
|
151
|
+
self._start_update_timer()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _start_update_timer(self):
|
|
155
|
+
l.debug('start update timer')
|
|
156
|
+
if not self.config.post_update_stats_interval:
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
def loop():
|
|
160
|
+
while True:
|
|
161
|
+
sleep(self.config.post_update_stats_interval)
|
|
162
|
+
self.update_post_stats()
|
|
163
|
+
|
|
164
|
+
self._thread = Thread(target=loop)
|
|
165
|
+
self._thread.daemon = True
|
|
166
|
+
self._thread.start()
|
|
167
|
+
|
|
168
|
+
def on_exit():
|
|
169
|
+
l.debug('stop update timer')
|
|
170
|
+
if self._thread:
|
|
171
|
+
self._thread.join(timeout=0)
|
|
172
|
+
|
|
173
|
+
register(on_exit)
|
|
174
|
+
|
|
175
|
+
|
|
123
176
|
def request(self, method: str, url: str, params: dict = {}, files: dict[str, tuple[str, BufferedReader | bytes]] = {}, level=AuthLevel.ACCESS):
|
|
124
177
|
"""Сделать запрос
|
|
125
178
|
|
|
@@ -137,17 +190,29 @@ class Client:
|
|
|
137
190
|
if level >= AuthLevel.ACCESS and self.access_token is None and url != 'v1/auth/refresh':
|
|
138
191
|
self.refresh_auth()
|
|
139
192
|
|
|
140
|
-
|
|
141
|
-
return fetch(self, method, url, params, files)
|
|
193
|
+
return fetch(self, method, url, params, files)
|
|
142
194
|
|
|
143
|
-
|
|
144
|
-
|
|
195
|
+
def request_sse(self, url: str):
|
|
196
|
+
l.debug('sse %s', url)
|
|
145
197
|
|
|
146
|
-
|
|
147
|
-
return _fetch()
|
|
148
|
-
except (UnauthorizedError, AccessTokenExpiredError):
|
|
198
|
+
if self.access_token is None and url != 'v1/auth/refresh':
|
|
149
199
|
self.refresh_auth()
|
|
150
|
-
|
|
200
|
+
|
|
201
|
+
return fetch_stream(self, url)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def update_post_stats(self):
|
|
205
|
+
if len(self.visible_posts) == 0:
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
l.debug('update post stats count=%s', len(self.visible_posts))
|
|
209
|
+
stats: list[dict] = get_stats(self, [post.id for post in self.visible_posts]).json().get('posts', [])
|
|
210
|
+
if len(stats) != len(self.visible_posts):
|
|
211
|
+
raise NotFoundError('Post(s)')
|
|
212
|
+
|
|
213
|
+
for post in self.visible_posts:
|
|
214
|
+
post._set_stats(next((stat for stat in stats if stat['id'] == str(post.id))))
|
|
215
|
+
|
|
151
216
|
|
|
152
217
|
def refresh_auth(self) -> str:
|
|
153
218
|
"""Обновить access token
|
|
@@ -272,6 +337,12 @@ class Client:
|
|
|
272
337
|
if hashtag:
|
|
273
338
|
return hashtag[0]
|
|
274
339
|
|
|
340
|
+
@overload
|
|
341
|
+
def get_follow_status(self, users: list[User | UUID | str]) -> dict[UUID, bool]: ...
|
|
342
|
+
|
|
343
|
+
@overload
|
|
344
|
+
def get_follow_status(self, users: User | UUID | str) -> bool: ...
|
|
345
|
+
|
|
275
346
|
def get_follow_status(self, users: list[User | UUID | str] | User | UUID | str) -> dict[UUID, bool] | bool:
|
|
276
347
|
"""Получить статус подписки
|
|
277
348
|
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import TYPE_CHECKING
|
|
1
3
|
from uuid import UUID
|
|
2
4
|
from datetime import datetime
|
|
3
5
|
from math import ceil
|
|
@@ -5,13 +7,14 @@ from math import ceil
|
|
|
5
7
|
from pydantic import Field, BaseModel, field_validator
|
|
6
8
|
|
|
7
9
|
from itd.base import ITDBaseModel, ITDList
|
|
8
|
-
from itd.client import Client
|
|
9
10
|
from itd.enums import CommentSorting, ReportTargetType, ReportReason
|
|
10
11
|
from itd.report import Report
|
|
11
12
|
from itd.utils import parse_datetime, to_nullable_uuid, format_attachments, ATTACHMENTS
|
|
12
13
|
from itd.api.comments import get_comments, add_comment, add_reply_comment, get_replies, like_comment, unlike_comment, delete_comment
|
|
13
14
|
from itd.user import User
|
|
14
15
|
from itd.file import CommentAttach
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from itd.client import Client
|
|
15
18
|
|
|
16
19
|
|
|
17
20
|
class Comment(ITDBaseModel):
|
|
@@ -130,6 +133,11 @@ class Comment(ITDBaseModel):
|
|
|
130
133
|
|
|
131
134
|
|
|
132
135
|
class _CommentValidate(BaseModel, Comment):
|
|
136
|
+
@field_validator('attachments', mode='plain')
|
|
137
|
+
@classmethod
|
|
138
|
+
def validate_attachments(cls, attachments: list[dict]):
|
|
139
|
+
return [CommentAttach(attach) for attach in attachments]
|
|
140
|
+
|
|
133
141
|
@field_validator('replies', mode='plain')
|
|
134
142
|
@classmethod
|
|
135
143
|
def validate_replies(cls, replies: list[dict]):
|