itd-sdk 2.3.0__tar.gz → 2.4.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.3.0 → itd_sdk-2.4.1}/PKG-INFO +5 -3
- {itd_sdk-2.3.0 → itd_sdk-2.4.1}/README.md +2 -2
- {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/__init__.py +2 -0
- {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/api/dwell.py +19 -19
- {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/api/notifications.py +1 -1
- itd_sdk-2.4.1/itd/api/platform.py +17 -0
- {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/api/posts.py +5 -0
- itd_sdk-2.4.1/itd/api/sessions.py +23 -0
- {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/base.py +94 -74
- itd_sdk-2.4.1/itd/clan.py +38 -0
- {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/client.py +75 -26
- {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/comment.py +2 -2
- {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/enums.py +17 -2
- {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/exceptions.py +9 -1
- {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/file.py +30 -20
- {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/hashtag.py +13 -4
- {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/logger.py +9 -11
- {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/notification.py +64 -26
- {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/post.py +60 -38
- {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/report.py +2 -4
- {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/request.py +3 -3
- itd_sdk-2.4.1/itd/session.py +91 -0
- {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/user.py +55 -35
- {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/utils.py +12 -2
- itd_sdk-2.4.1/itd/version.py +73 -0
- {itd_sdk-2.3.0 → itd_sdk-2.4.1}/pyproject.toml +4 -1
- itd_sdk-2.3.0/itd/clan.py +0 -17
- {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/_default.py +0 -0
- {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/api/__init__.py +0 -0
- {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/api/auth.py +0 -0
- {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/api/comments.py +0 -0
- {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/api/etc.py +0 -0
- {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/api/files.py +0 -0
- {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/api/hashtags.py +0 -0
- {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/api/pins.py +0 -0
- {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/api/polls.py +0 -0
- {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/api/reports.py +0 -0
- {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/api/search.py +0 -0
- {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/api/subscription.py +0 -0
- {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/api/users.py +0 -0
- {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/api/verification.py +0 -0
- {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/pin.py +0 -0
- {itd_sdk-2.3.0 → itd_sdk-2.4.1}/itd/poll.py +0 -0
- {itd_sdk-2.3.0 → itd_sdk-2.4.1}/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.1
|
|
4
4
|
Summary: ITD client for python
|
|
5
5
|
Keywords: itd,itdsocial
|
|
6
6
|
Author: firedotguy
|
|
@@ -14,18 +14,20 @@ Classifier: Framework :: Pydantic :: 2
|
|
|
14
14
|
Requires-Dist: requests~=2.32.5
|
|
15
15
|
Requires-Dist: pydantic>=2.12.5,<2.14.0
|
|
16
16
|
Requires-Dist: sseclient-py~=1.9.0
|
|
17
|
+
Requires-Dist: dateparser>=1.2.2 ; extra == 'date'
|
|
17
18
|
Requires-Dist: filetype>=1.2.0 ; extra == 'filetype'
|
|
18
19
|
Requires-Dist: rich>=15.0.0 ; extra == 'logs'
|
|
19
20
|
Requires-Python: >=3.9
|
|
20
21
|
Project-URL: Homepage, https://itdsdk.qzz.io
|
|
21
22
|
Project-URL: Documentation, https://itdsdk.qzz.io/docs
|
|
23
|
+
Provides-Extra: date
|
|
22
24
|
Provides-Extra: filetype
|
|
23
25
|
Provides-Extra: logs
|
|
24
26
|
Description-Content-Type: text/markdown
|
|
25
27
|
|
|
26
28
|
# itd-sdk
|
|
27
29
|
Клиент ITD для python
|
|
28
|
-
Документация (beta): https://
|
|
30
|
+
Документация (beta): https://itdsdk.qzz.io/docs
|
|
29
31
|
|
|
30
32
|
## Установка
|
|
31
33
|
|
|
@@ -123,7 +125,7 @@ fetch(c, 'метод', 'эндпоинт', {'данные': 'данные'})
|
|
|
123
125
|
## Прочее
|
|
124
126
|
- Лицезия: [MIT](./LICENSE)
|
|
125
127
|
- Автор:
|
|
126
|
-
- ИТД: [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)
|
|
127
129
|
- ТГ: [@desicars](https://t.me/desicars)
|
|
128
130
|
|
|
129
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
|
|
@@ -1,19 +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)})
|
|
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 revoke(client: Client, id: UUID):
|
|
18
|
+
return client.request('delete', f'v1/auth/sessions/{id}')
|
|
19
|
+
|
|
20
|
+
@catch_errors()
|
|
21
|
+
@rate_limit()
|
|
22
|
+
def revoke_all(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,14 +12,22 @@ 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
|
|
16
|
-
|
|
15
|
+
from itd.exceptions import (
|
|
16
|
+
ITDException, ValidationError, RateLimitError, UnauthorizedError, AccessTokenExpiredError,
|
|
17
|
+
DEFAULT_ERRORS, AccountDeletedError
|
|
18
|
+
)
|
|
19
|
+
from itd.enums import All, ALL, DebugResponseMode, RateLimitMode, BATCH, Batch, LoadStatus
|
|
17
20
|
if TYPE_CHECKING:
|
|
18
21
|
from itd.client import Client
|
|
19
22
|
|
|
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)
|
|
@@ -39,8 +47,7 @@ def _field_has_default(cls: type, name: str) -> bool:
|
|
|
39
47
|
class ITDBaseModel:
|
|
40
48
|
"""Базовый класс модельки"""
|
|
41
49
|
_refreshable: bool = True
|
|
42
|
-
|
|
43
|
-
_loading: bool = False
|
|
50
|
+
load_status: LoadStatus = LoadStatus.NO
|
|
44
51
|
_load_with_parent: bool = True # load parent model if model called
|
|
45
52
|
_validator: Callable[[Any], type[BaseModel]] | None = None # callable (pls use lambda), becuase we havent validator at that moment (it depends on this class)
|
|
46
53
|
|
|
@@ -53,49 +60,65 @@ class ITDBaseModel:
|
|
|
53
60
|
value._client = client
|
|
54
61
|
object.__setattr__(self, name, value)
|
|
55
62
|
|
|
56
|
-
def _post_refresh(self):
|
|
57
|
-
pass
|
|
63
|
+
def _post_refresh(self): ...
|
|
58
64
|
|
|
59
65
|
@property
|
|
60
66
|
def client(self) -> Client:
|
|
61
67
|
return self._client
|
|
62
68
|
|
|
63
|
-
def
|
|
64
|
-
|
|
65
|
-
|
|
69
|
+
def _refresh(self, *, client: Client) -> dict: ...
|
|
70
|
+
|
|
71
|
+
def refresh(self, *, client: Client | None = None) -> Any:
|
|
72
|
+
self.load_status = LoadStatus.LOADING
|
|
73
|
+
self._fill_from_data(self._refresh(client=client or self.client))
|
|
74
|
+
self.load_status = LoadStatus.FULL
|
|
75
|
+
return self
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _fill_from_data(self, data: dict):
|
|
79
|
+
assert self._validator, 'Unable to use fill_from_data without a validator'
|
|
80
|
+
validated = self._validator().model_validate(data) # pyright: ignore[reportCallIssue]
|
|
81
|
+
self._fields_from_data = validated.model_fields_set
|
|
82
|
+
for name, value in validated.__dict__.items():
|
|
83
|
+
setattr(self, name, value)
|
|
84
|
+
|
|
85
|
+
self._post_refresh()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@classmethod
|
|
89
|
+
def from_dict(cls, data: dict, *, client: Client | None = None):
|
|
90
|
+
instance = cls.__new__(cls)
|
|
91
|
+
ITDBaseModel.__init__(instance, client)
|
|
92
|
+
|
|
93
|
+
instance._fill_from_data(data)
|
|
94
|
+
instance.load_status = LoadStatus.PARTIALLY
|
|
95
|
+
return instance
|
|
66
96
|
|
|
67
97
|
|
|
68
98
|
if not TYPE_CHECKING:
|
|
69
99
|
def __getattribute__(self, name: str) -> Any:
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
any(triggers.values()) and
|
|
88
|
-
_getattr(self, 'client').config.auto_load and
|
|
89
|
-
not (name == 'comments' and not _getattr(self, 'client').config.load_comments_from_post) # да я хотел сделать нормально, поставить проперти на comments и тд, но это херня кака ято крч просто нахардкожу
|
|
90
|
-
):
|
|
91
|
-
l.info('load %s.%s reason=%s', self.__class__.__name__.lower(), name,
|
|
92
|
-
next((k for k, v in triggers.items() if v))
|
|
93
|
-
)
|
|
94
|
-
self.refresh()
|
|
100
|
+
value = _getattr(self, name)
|
|
101
|
+
if (
|
|
102
|
+
name.startswith('_') or # приватный аттрибут
|
|
103
|
+
name in ('client', 'model_fields_set', 'load_status') or
|
|
104
|
+
not _getattr(self, '_refreshable') or # не рефрешабельная модель
|
|
105
|
+
not _getattr(self, 'client').config.auto_load or # отключено в конфиге
|
|
106
|
+
callable(value) or # функция
|
|
107
|
+
self.load_status == LoadStatus.LOADING or
|
|
108
|
+
(not _getattr(self, 'client').config.load_comments_from_post and _is_comments_on_post(value, self)) or
|
|
109
|
+
(isinstance(value, ITDBaseModel) and not value._load_with_parent)
|
|
110
|
+
):
|
|
111
|
+
# l.debug('return %s as is', name)
|
|
112
|
+
return object.__getattribute__(self, name)
|
|
113
|
+
|
|
114
|
+
if isinstance(value, FieldInfo) or (self.load_status != LoadStatus.FULL and name not in self._fields_from_data):
|
|
115
|
+
l.info('refresh %s field=%s load_status=%s', self.__class__.__name__, name, self.load_status.value)
|
|
116
|
+
self.refresh()
|
|
95
117
|
|
|
96
118
|
return object.__getattribute__(self, name)
|
|
97
119
|
|
|
98
120
|
|
|
121
|
+
|
|
99
122
|
T = TypeVar('T', bound=ITDBaseModel)
|
|
100
123
|
|
|
101
124
|
class ITDList[T](ITDBaseModel, list[T]):
|
|
@@ -178,7 +201,7 @@ class ITDList[T](ITDBaseModel, list[T]):
|
|
|
178
201
|
def _get_objects(data: dict) -> list[dict]:
|
|
179
202
|
return []
|
|
180
203
|
|
|
181
|
-
def refresh(self, count: int | All | Batch | None = None, limit: int | Batch = BATCH, client: Client | None = None) -> list[T]:
|
|
204
|
+
def refresh(self, count: int | All | Batch | None = None, limit: int | Batch = BATCH, *, client: Client | None = None) -> list[T]:
|
|
182
205
|
"""Обновить список (удалить все элементы и загрузить заново)
|
|
183
206
|
|
|
184
207
|
Args:
|
|
@@ -211,18 +234,33 @@ class ITDList[T](ITDBaseModel, list[T]):
|
|
|
211
234
|
"""
|
|
212
235
|
return self.load(ALL, limit, client)
|
|
213
236
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
237
|
+
@overload
|
|
238
|
+
def __getitem__(self, index: int) -> T: ...
|
|
239
|
+
|
|
240
|
+
@overload
|
|
241
|
+
def __getitem__(self, index: slice) -> list[T]: ...
|
|
242
|
+
|
|
243
|
+
def __getitem__(self, index: int | slice) -> T | list[T]: # pyright: ignore[reportIncompatibleMethodOverride]
|
|
244
|
+
|
|
245
|
+
if isinstance(index, slice):
|
|
246
|
+
value: int | None = index.stop
|
|
247
|
+
else:
|
|
248
|
+
value = index
|
|
249
|
+
|
|
250
|
+
if ((value is not None and value > len(self) - 1) or value is None) and self.client.config.load_on_getitem is not None:
|
|
251
|
+
if value:
|
|
252
|
+
self._min_total = value + 1
|
|
253
|
+
|
|
254
|
+
if value is None or isinstance(self.client.config.load_on_getitem, All):
|
|
218
255
|
l.debug('getitem load all')
|
|
219
256
|
self.load_all()
|
|
220
257
|
elif isinstance(self.client.config.load_on_getitem, Batch):
|
|
221
258
|
l.debug('getitem load batch')
|
|
222
259
|
self.load(BATCH)
|
|
223
260
|
else:
|
|
224
|
-
l.debug('getitem load %s',
|
|
225
|
-
self.load(
|
|
261
|
+
l.debug('getitem load %s', value - len(self) + self.client.config.load_on_getitem)
|
|
262
|
+
self.load(value - len(self) + self.client.config.load_on_getitem)
|
|
263
|
+
|
|
226
264
|
return super().__getitem__(index)
|
|
227
265
|
|
|
228
266
|
def __next__(self) -> T:
|
|
@@ -249,29 +287,6 @@ class ITDList[T](ITDBaseModel, list[T]):
|
|
|
249
287
|
return self.load_all()
|
|
250
288
|
|
|
251
289
|
|
|
252
|
-
def refresh_wrapper(func):
|
|
253
|
-
"""Декоратор для self.refresh"""
|
|
254
|
-
@wraps(func)
|
|
255
|
-
def wrapper(self, client: Client | None = None):
|
|
256
|
-
# if self._loading:
|
|
257
|
-
# return
|
|
258
|
-
|
|
259
|
-
# self._loading = True
|
|
260
|
-
data = func(self, client or self.client)
|
|
261
|
-
if self._validator:
|
|
262
|
-
validated = self._validator().model_validate(data)
|
|
263
|
-
self._fields_from_data = validated.model_fields_set
|
|
264
|
-
l.debug('refresh %s', self.__class__.__name__.lower())
|
|
265
|
-
for name, value in validated.__dict__.items():
|
|
266
|
-
setattr(self, name, value)
|
|
267
|
-
|
|
268
|
-
# self._loading = False
|
|
269
|
-
self._loaded = True
|
|
270
|
-
self._post_refresh()
|
|
271
|
-
|
|
272
|
-
return wrapper
|
|
273
|
-
|
|
274
|
-
|
|
275
290
|
def _filter_bytes(args: tuple):
|
|
276
291
|
filtered = []
|
|
277
292
|
for arg in args:
|
|
@@ -323,16 +338,17 @@ def catch_errors(*exceptions: ITDException):
|
|
|
323
338
|
):
|
|
324
339
|
if isinstance(exception, ValidationError):
|
|
325
340
|
exception.text = json.get('error', {}).get('message', 'Failed validation')
|
|
341
|
+
|
|
326
342
|
if isinstance(exception, RateLimitError) and isinstance(json.get('error'), dict):
|
|
327
343
|
exception.retry_after = json.get('error', {}).get('retryAfter', 0)
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
client.refresh_auth()
|
|
332
|
-
finally:
|
|
333
|
-
client._refreshing = False
|
|
344
|
+
|
|
345
|
+
if isinstance(exception, (UnauthorizedError, AccessTokenExpiredError)) and client.refresh_token:
|
|
346
|
+
client.refresh_auth()
|
|
334
347
|
return wrapper(client, *args, **kwargs)
|
|
335
348
|
|
|
349
|
+
if isinstance(exception, AccountDeletedError):
|
|
350
|
+
exception.can_restore = json.get('error', {}).get('canRestore', True)
|
|
351
|
+
|
|
336
352
|
raise exception
|
|
337
353
|
|
|
338
354
|
if client.config.debug_response == DebugResponseMode.AFTER:
|
|
@@ -369,7 +385,7 @@ def rate_limit(delay_min: float | None = None, delay_mid: float | None = None, d
|
|
|
369
385
|
else:
|
|
370
386
|
delay = client.config._rate_limit_default
|
|
371
387
|
|
|
372
|
-
if datetime.now() - timedelta(seconds=delay) < client.last_actions.get(func.__name__, datetime(
|
|
388
|
+
if datetime.now() - timedelta(seconds=delay) < client.last_actions.get(func.__name__, datetime(1990, 1, 1)):
|
|
373
389
|
delay -= (datetime.now() - client.last_actions[func.__name__]).seconds
|
|
374
390
|
l.debug('anti rate limit on %s; wait %ss', func.__name__, delay)
|
|
375
391
|
sleep(max(delay, 0))
|
|
@@ -382,8 +398,12 @@ def rate_limit(delay_min: float | None = None, delay_mid: float | None = None, d
|
|
|
382
398
|
try:
|
|
383
399
|
return func(client, *args, **kwargs)
|
|
384
400
|
except (client.config._retry_exceptions) as e:
|
|
385
|
-
|
|
386
|
-
|
|
401
|
+
if getattr(e, 'retry_after', 0) > client.config.retry_max_retry_after:
|
|
402
|
+
l.error('too large rate limit')
|
|
403
|
+
raise
|
|
404
|
+
|
|
405
|
+
retry_after = getattr(e, 'retry_after', client.config.retry_delay)
|
|
406
|
+
l.warning('%s on %s: wait %ss', e.__class__.__name__, func.__name__, retry_after)
|
|
387
407
|
sleep(retry_after)
|
|
388
408
|
|
|
389
409
|
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, *, client = None):
|
|
31
|
+
l.warning('TopClans.refresh is deprecated and will be removed in 2.6.0. Please 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,14 +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
|
|
17
|
-
from itd.post import DwellTracker
|
|
21
|
+
from itd.post import DwellTracker, Post
|
|
18
22
|
from itd.api.auth import refresh_token, change_password, logout
|
|
19
23
|
from itd.api.search import search
|
|
24
|
+
from itd.api.posts import get_stats
|
|
20
25
|
from itd.api.users import get_follow_status
|
|
21
26
|
from itd.utils import to_uuid, get_sdk_user_agent
|
|
22
27
|
from itd.logger import get_logger
|
|
@@ -57,12 +62,11 @@ class Config:
|
|
|
57
62
|
|
|
58
63
|
parse_mode: ParseMode = ParseMode.NO
|
|
59
64
|
|
|
60
|
-
rate_limit_wait: int | None = None # DEPRECATED
|
|
61
|
-
retry_on_rate_limits: bool | None = None # DEPRECATED
|
|
62
65
|
retry_enabled: bool = True
|
|
63
66
|
retry_delay: float = 10 # delay before next attempt (after rate limit error) if retry_after is not provided in request
|
|
64
67
|
retry_max_retries: int | None = None # none for no limit
|
|
65
68
|
retry_exceptions: tuple[type[Exception]] | list[type[Exception]] | None = None
|
|
69
|
+
retry_max_retry_after: int = 500
|
|
66
70
|
|
|
67
71
|
bypass_auth_level: bool = False
|
|
68
72
|
|
|
@@ -70,6 +74,14 @@ class Config:
|
|
|
70
74
|
dwell_max_buffer: int = 20
|
|
71
75
|
dwell_send_interval: float = 2
|
|
72
76
|
dwell_save_on_quit: bool = True
|
|
77
|
+
dwell_wait_durations: bool = True
|
|
78
|
+
post_view_increment: bool = False
|
|
79
|
+
|
|
80
|
+
post_update_stats: bool = False
|
|
81
|
+
post_update_stats_interval: int = 3
|
|
82
|
+
|
|
83
|
+
view_read_speed: int = 250 # in WPM # https://scholarwithin.com/average-reading-speed
|
|
84
|
+
view_images_speed: int = 130 # https://news.mit.edu/2014/in-the-blink-of-an-eye-0116
|
|
73
85
|
|
|
74
86
|
def __post_init__(self):
|
|
75
87
|
if self.rate_limit_default:
|
|
@@ -96,28 +108,21 @@ class Config:
|
|
|
96
108
|
case _:
|
|
97
109
|
self._user_agent = self.user_agent
|
|
98
110
|
|
|
99
|
-
if self.rate_limit_wait is not None:
|
|
100
|
-
l.warning('config.rate_limit_wait is deprecated and will be removed in 2.4.0. Please use config.retry_delay')
|
|
101
|
-
self.retry_delay = self.rate_limit_wait
|
|
102
|
-
if self.retry_on_rate_limits is not None:
|
|
103
|
-
l.warning('config.retry_on_rate_limits is deprecated and will be removed in 2.4.0. Please use config.retry_enabled')
|
|
104
|
-
self.retry_enabled = self.retry_on_rate_limits
|
|
105
111
|
|
|
106
112
|
self._retry_exceptions = (tuple(self.retry_exceptions) if isinstance(self.retry_exceptions, list) else self.retry_exceptions) or (RateLimitError, InternalError, RequestException)
|
|
107
113
|
|
|
108
114
|
|
|
109
115
|
|
|
110
116
|
class Client:
|
|
111
|
-
auth_level: AuthLevel = AuthLevel.NO
|
|
112
|
-
access_token: str | None = None
|
|
113
|
-
refresh_token: str | None = None
|
|
114
|
-
_user = None
|
|
115
|
-
_refreshing: bool = False
|
|
116
|
-
|
|
117
117
|
def __init__(self, refresh: str | None = None, access: str | None = None, config: Config = Config()):
|
|
118
118
|
l.info('init client refresh=%s access=%s', refresh is not None, access is not None)
|
|
119
119
|
self.config = config
|
|
120
120
|
self.last_actions: dict[str, datetime] = {}
|
|
121
|
+
self.auth_level: AuthLevel = AuthLevel.NO
|
|
122
|
+
self.access_token: str | None = None
|
|
123
|
+
self.refresh_token: str | None = None
|
|
124
|
+
self._user: Me | None = None
|
|
125
|
+
self.visible_posts: list[Post] = []
|
|
121
126
|
|
|
122
127
|
self.session = Session()
|
|
123
128
|
adapter = HTTPAdapter(pool_connections=1, pool_maxsize=10, pool_block=False) # idk what is this, (claude added) just for better stability
|
|
@@ -143,6 +148,32 @@ class Client:
|
|
|
143
148
|
else:
|
|
144
149
|
self.dwell_tracker = None
|
|
145
150
|
|
|
151
|
+
if self.config.post_update_stats:
|
|
152
|
+
self._start_update_timer()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _start_update_timer(self):
|
|
156
|
+
l.debug('start update timer')
|
|
157
|
+
if not self.config.post_update_stats_interval:
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
def loop():
|
|
161
|
+
while True:
|
|
162
|
+
sleep(self.config.post_update_stats_interval)
|
|
163
|
+
self.update_post_stats()
|
|
164
|
+
|
|
165
|
+
self._thread = Thread(target=loop)
|
|
166
|
+
self._thread.daemon = True
|
|
167
|
+
self._thread.start()
|
|
168
|
+
|
|
169
|
+
def on_exit():
|
|
170
|
+
l.debug('stop update timer')
|
|
171
|
+
if self._thread:
|
|
172
|
+
self._thread.join(timeout=0)
|
|
173
|
+
|
|
174
|
+
register(on_exit)
|
|
175
|
+
|
|
176
|
+
|
|
146
177
|
def request(self, method: str, url: str, params: dict = {}, files: dict[str, tuple[str, BufferedReader | bytes]] = {}, level=AuthLevel.ACCESS):
|
|
147
178
|
"""Сделать запрос
|
|
148
179
|
|
|
@@ -160,17 +191,29 @@ class Client:
|
|
|
160
191
|
if level >= AuthLevel.ACCESS and self.access_token is None and url != 'v1/auth/refresh':
|
|
161
192
|
self.refresh_auth()
|
|
162
193
|
|
|
163
|
-
|
|
164
|
-
return fetch(self, method, url, params, files)
|
|
194
|
+
return fetch(self, method, url, params, files)
|
|
165
195
|
|
|
166
|
-
|
|
167
|
-
|
|
196
|
+
def request_sse(self, url: str):
|
|
197
|
+
l.debug('sse %s', url)
|
|
168
198
|
|
|
169
|
-
|
|
170
|
-
return _fetch()
|
|
171
|
-
except (UnauthorizedError, AccessTokenExpiredError):
|
|
199
|
+
if self.access_token is None and url != 'v1/auth/refresh':
|
|
172
200
|
self.refresh_auth()
|
|
173
|
-
|
|
201
|
+
|
|
202
|
+
return fetch_stream(self, url)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def update_post_stats(self):
|
|
206
|
+
if len(self.visible_posts) == 0:
|
|
207
|
+
return
|
|
208
|
+
|
|
209
|
+
l.debug('update post stats count=%s', len(self.visible_posts))
|
|
210
|
+
stats: list[dict] = get_stats(self, [post.id for post in self.visible_posts]).json().get('posts', [])
|
|
211
|
+
if len(stats) != len(self.visible_posts):
|
|
212
|
+
raise NotFoundError('Post(s)')
|
|
213
|
+
|
|
214
|
+
for post in self.visible_posts:
|
|
215
|
+
post._set_stats(next((stat for stat in stats if stat['id'] == str(post.id))))
|
|
216
|
+
|
|
174
217
|
|
|
175
218
|
def refresh_auth(self) -> str:
|
|
176
219
|
"""Обновить access token
|
|
@@ -243,7 +286,7 @@ class Client:
|
|
|
243
286
|
tuple[list[User], list[Hashtag]]: Результат поиска
|
|
244
287
|
"""
|
|
245
288
|
res = search(self, query, users_limit, hashtags_limit).json()['data']
|
|
246
|
-
return [User._from_dict(user,
|
|
289
|
+
return [User._from_dict(user, client=self) for user in res['users']], [Hashtag._from_dict(hashtag, self) for hashtag in res['hashtags']]
|
|
247
290
|
|
|
248
291
|
def search_users(self, query: str, limit: int = 20) -> list[User]:
|
|
249
292
|
"""Поиск пользователей
|
|
@@ -295,6 +338,12 @@ class Client:
|
|
|
295
338
|
if hashtag:
|
|
296
339
|
return hashtag[0]
|
|
297
340
|
|
|
341
|
+
@overload
|
|
342
|
+
def get_follow_status(self, users: list[User | UUID | str]) -> dict[UUID, bool]: ...
|
|
343
|
+
|
|
344
|
+
@overload
|
|
345
|
+
def get_follow_status(self, users: User | UUID | str) -> bool: ...
|
|
346
|
+
|
|
298
347
|
def get_follow_status(self, users: list[User | UUID | str] | User | UUID | str) -> dict[UUID, bool] | bool:
|
|
299
348
|
"""Получить статус подписки
|
|
300
349
|
|
|
@@ -152,12 +152,12 @@ class _CommentValidate(BaseModel, Comment):
|
|
|
152
152
|
@classmethod
|
|
153
153
|
def validate_reply_to(cls, reply_to: dict | None):
|
|
154
154
|
if reply_to is not None:
|
|
155
|
-
return User._from_dict(reply_to
|
|
155
|
+
return User._from_dict(reply_to)
|
|
156
156
|
|
|
157
157
|
@field_validator('author', mode='plain')
|
|
158
158
|
@classmethod
|
|
159
159
|
def validate_author(cls, author: dict):
|
|
160
|
-
return User._from_dict(author
|
|
160
|
+
return User._from_dict(author)
|
|
161
161
|
|
|
162
162
|
|
|
163
163
|
|