itd-sdk 2.1.2__tar.gz → 2.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. {itd_sdk-2.1.2 → itd_sdk-2.2.0}/PKG-INFO +1 -1
  2. {itd_sdk-2.1.2 → itd_sdk-2.2.0}/itd/api/auth.py +5 -4
  3. {itd_sdk-2.1.2 → itd_sdk-2.2.0}/itd/api/hashtags.py +3 -2
  4. {itd_sdk-2.1.2 → itd_sdk-2.2.0}/itd/api/search.py +2 -1
  5. {itd_sdk-2.1.2 → itd_sdk-2.2.0}/itd/base.py +4 -5
  6. {itd_sdk-2.1.2 → itd_sdk-2.2.0}/itd/client.py +33 -11
  7. {itd_sdk-2.1.2 → itd_sdk-2.2.0}/itd/enums.py +25 -5
  8. {itd_sdk-2.1.2 → itd_sdk-2.2.0}/itd/exceptions.py +6 -3
  9. {itd_sdk-2.1.2 → itd_sdk-2.2.0}/itd/post.py +22 -4
  10. {itd_sdk-2.1.2 → itd_sdk-2.2.0}/pyproject.toml +1 -1
  11. {itd_sdk-2.1.2 → itd_sdk-2.2.0}/README.md +0 -0
  12. {itd_sdk-2.1.2 → itd_sdk-2.2.0}/itd/__init__.py +0 -0
  13. {itd_sdk-2.1.2 → itd_sdk-2.2.0}/itd/_default.py +0 -0
  14. {itd_sdk-2.1.2 → itd_sdk-2.2.0}/itd/api/__init__.py +0 -0
  15. {itd_sdk-2.1.2 → itd_sdk-2.2.0}/itd/api/comments.py +0 -0
  16. {itd_sdk-2.1.2 → itd_sdk-2.2.0}/itd/api/etc.py +0 -0
  17. {itd_sdk-2.1.2 → itd_sdk-2.2.0}/itd/api/files.py +0 -0
  18. {itd_sdk-2.1.2 → itd_sdk-2.2.0}/itd/api/notifications.py +0 -0
  19. {itd_sdk-2.1.2 → itd_sdk-2.2.0}/itd/api/pins.py +0 -0
  20. {itd_sdk-2.1.2 → itd_sdk-2.2.0}/itd/api/polls.py +0 -0
  21. {itd_sdk-2.1.2 → itd_sdk-2.2.0}/itd/api/posts.py +0 -0
  22. {itd_sdk-2.1.2 → itd_sdk-2.2.0}/itd/api/reports.py +0 -0
  23. {itd_sdk-2.1.2 → itd_sdk-2.2.0}/itd/api/subscription.py +0 -0
  24. {itd_sdk-2.1.2 → itd_sdk-2.2.0}/itd/api/users.py +0 -0
  25. {itd_sdk-2.1.2 → itd_sdk-2.2.0}/itd/api/verification.py +0 -0
  26. {itd_sdk-2.1.2 → itd_sdk-2.2.0}/itd/clan.py +0 -0
  27. {itd_sdk-2.1.2 → itd_sdk-2.2.0}/itd/comment.py +0 -0
  28. {itd_sdk-2.1.2 → itd_sdk-2.2.0}/itd/file.py +0 -0
  29. {itd_sdk-2.1.2 → itd_sdk-2.2.0}/itd/hashtag.py +0 -0
  30. {itd_sdk-2.1.2 → itd_sdk-2.2.0}/itd/logger.py +0 -0
  31. {itd_sdk-2.1.2 → itd_sdk-2.2.0}/itd/notification.py +0 -0
  32. {itd_sdk-2.1.2 → itd_sdk-2.2.0}/itd/pin.py +0 -0
  33. {itd_sdk-2.1.2 → itd_sdk-2.2.0}/itd/poll.py +0 -0
  34. {itd_sdk-2.1.2 → itd_sdk-2.2.0}/itd/report.py +0 -0
  35. {itd_sdk-2.1.2 → itd_sdk-2.2.0}/itd/request.py +0 -0
  36. {itd_sdk-2.1.2 → itd_sdk-2.2.0}/itd/span.py +0 -0
  37. {itd_sdk-2.1.2 → itd_sdk-2.2.0}/itd/user.py +0 -0
  38. {itd_sdk-2.1.2 → itd_sdk-2.2.0}/itd/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: itd-sdk
3
- Version: 2.1.2
3
+ Version: 2.2.0
4
4
  Summary: ITD client for python
5
5
  Keywords: itd,itdsocial
6
6
  Author: firedotguy
@@ -3,15 +3,16 @@ from typing import TYPE_CHECKING
3
3
 
4
4
  from requests import Response
5
5
 
6
- from itd.base import catch_errors
7
- from itd.request import fetch
6
+ from itd.base import catch_errors, rate_limit
7
+ from itd.enums import AuthLevel
8
8
  from itd.exceptions import InvalidPasswordError, SamePasswordError, InvalidOldPasswordError, SessionNotFoundError, SessionExpiredError, SessionRevokedError
9
9
  if TYPE_CHECKING:
10
10
  from itd.client import Client
11
11
 
12
+ @rate_limit()
12
13
  @catch_errors(SessionExpiredError(), SessionNotFoundError(), SessionRevokedError())
13
14
  def refresh_token(client: Client) -> Response:
14
- return fetch(client, 'post', 'v1/auth/refresh')
15
+ return client.request('post', 'v1/auth/refresh', level=AuthLevel.REFRESH)
15
16
 
16
17
  @catch_errors(InvalidPasswordError(), SamePasswordError(), InvalidOldPasswordError())
17
18
  def change_password(client: Client, old: str, new: str) -> Response:
@@ -19,4 +20,4 @@ def change_password(client: Client, old: str, new: str) -> Response:
19
20
 
20
21
  @catch_errors()
21
22
  def logout(client: Client) -> Response:
22
- return client.request('post', 'v1/auth/logout')
23
+ return client.request('post', 'v1/auth/logout', level=AuthLevel.REFRESH)
@@ -4,15 +4,16 @@ from typing import TYPE_CHECKING
4
4
 
5
5
  from itd.exceptions import TooLargeError, NotFoundError
6
6
  from itd.base import catch_errors, rate_limit
7
+ from itd.enums import AuthLevel
7
8
  if TYPE_CHECKING:
8
9
  from itd.client import Client
9
10
 
10
11
  @rate_limit()
11
12
  @catch_errors()
12
13
  def get_hashtags(client: Client, limit: int = 10):
13
- return client.request('get', 'hashtags/trending', {'limit': limit})
14
+ return client.request('get', 'hashtags/trending', {'limit': limit}, level=AuthLevel.NO)
14
15
 
15
16
  @rate_limit()
16
17
  @catch_errors(TooLargeError('Hashtag'), NotFoundError('Hashtag', json_check=lambda json: json.get('data', {}).get('hashtag', '') is None))
17
18
  def get_posts_by_hashtag(client: Client, hashtag: str, cursor: UUID | None = None, limit: int = 20):
18
- return client.request('get', f'hashtags/{hashtag}/posts', {'limit': limit, 'cursor': cursor})
19
+ return client.request('get', f'hashtags/{hashtag}/posts', {'limit': limit, 'cursor': cursor}, level=AuthLevel.NO)
@@ -4,9 +4,10 @@ from typing import TYPE_CHECKING
4
4
  if TYPE_CHECKING:
5
5
  from itd.client import Client
6
6
  from itd.exceptions import ValidationError
7
+ from itd.enums import AuthLevel
7
8
  from itd.base import catch_errors, rate_limit
8
9
 
9
10
  @rate_limit(None, 0.5, 3)
10
11
  @catch_errors(ValidationError())
11
12
  def search(client: Client, query: str, user_limit: int = 5, hashtag_limit: int = 5):
12
- return client.request('get', 'search', {'userLimit': user_limit, 'hashtagLimit': hashtag_limit, 'q': query})
13
+ return client.request('get', 'search', {'userLimit': user_limit, 'hashtagLimit': hashtag_limit, 'q': query}, level=AuthLevel.NO)
@@ -147,7 +147,6 @@ class ITDList[T](ITDBaseModel, list[T]):
147
147
  length = len(objects)
148
148
  if objects and (client or self.client).config.userposts_add_pinned_post and length == batch + 1: # skip pinned post
149
149
  length -= 1
150
- l.debug('%s', objects)
151
150
 
152
151
  if left is not None:
153
152
  left -= length
@@ -370,15 +369,15 @@ def rate_limit(delay_min: float | None = None, delay_mid: float | None = None, d
370
369
  sleep(max(delay, 0))
371
370
  client.last_actions[func.__name__] = datetime.now()
372
371
 
373
- if not client.config.retry_on_rate_limits:
372
+ if not client.config.retry_enabled:
374
373
  return func(client, *args, **kwargs)
375
374
 
376
375
  while True:
377
376
  try:
378
377
  return func(client, *args, **kwargs)
379
- except (RateLimitError, InternalError) as e:
380
- retry_after = getattr(e, 'retry_after', client.config.rate_limit_wait) or client.config.rate_limit_wait
381
- l.info('rate limit on %s; wait %ss', func.__name__, retry_after)
378
+ except (client.config._retry_exceptions) as e:
379
+ retry_after = getattr(e, 'retry_after', client.config.rate_limit_wait) or client.config.retry_delay
380
+ l.info('%s on %s: wait %ss', e.__class__.__name__, func.__name__, retry_after)
382
381
  sleep(retry_after)
383
382
 
384
383
  return wrapper
@@ -6,12 +6,13 @@ from dataclasses import dataclass, field
6
6
  from requests import Session
7
7
  from requests.utils import default_user_agent
8
8
  from requests.adapters import HTTPAdapter
9
+ from requests.exceptions import RequestException
9
10
 
10
11
  from itd._default import _default_client, set_default_client
11
- from itd.exceptions import UnauthorizedError, InsufficientAuthLevelError, AccessTokenExpiredError
12
+ from itd.exceptions import UnauthorizedError, InsufficientAuthLevelError, AccessTokenExpiredError, RateLimitError, InternalError
12
13
  from itd.hashtag import Hashtag
13
14
  from itd.request import fetch, decode_jwt_payload
14
- from itd.enums import RateLimitMode, All, DebugResponseMode, ParseMode, Batch, BATCH, UserAgent
15
+ from itd.enums import RateLimitMode, All, DebugResponseMode, ParseMode, Batch, BATCH, UserAgent, AuthLevel
15
16
  from itd.user import Me, User
16
17
  from itd.api.auth import refresh_token, change_password, logout
17
18
  from itd.api.search import search
@@ -45,8 +46,13 @@ class Config:
45
46
  solve_challenge: bool = True
46
47
  load_comments_from_post: bool = False
47
48
  parse_mode: ParseMode = ParseMode.NO
48
- rate_limit_wait: int = 10 # delay before next attempt (after rate limit error) if retry_after is not provided
49
- retry_on_rate_limits: bool = True
49
+ rate_limit_wait: int | None = None # DEPRECATED
50
+ retry_on_rate_limits: bool | None = None # DEPRECATED
51
+ retry_enabled: bool = True
52
+ retry_delay: float = 10 # delay before next attempt (after rate limit error) if retry_after is not provided in request
53
+ retry_max_retries: int | None = None # none for no limit
54
+ retry_exceptions: tuple[type[Exception]] | list[type[Exception]] | None = None
55
+ bypass_auth_level: bool = False
50
56
 
51
57
  def __post_init__(self):
52
58
  if self.rate_limit_default:
@@ -73,10 +79,19 @@ class Config:
73
79
  case _:
74
80
  self._user_agent = self.user_agent
75
81
 
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
+
89
+ self._retry_exceptions = (tuple(self.retry_exceptions) if isinstance(self.retry_exceptions, list) else self.retry_exceptions) or (RateLimitError, InternalError, RequestException)
76
90
 
77
91
 
78
92
 
79
93
  class Client:
94
+ auth_level: AuthLevel = AuthLevel.NO
80
95
  access_token: str | None = None
81
96
  refresh_token: str | None = None
82
97
  _user = None
@@ -92,18 +107,20 @@ class Client:
92
107
  self.session.mount('https://', adapter)
93
108
 
94
109
  if access:
110
+ self.auth_level = AuthLevel.ACCESS
95
111
  self.access_token = access.replace('Bearer ', '')
96
112
 
97
113
  if refresh:
114
+ self.auth_level = AuthLevel.REFRESH
98
115
  self.refresh_token = refresh
99
116
  self.session.cookies.set('refresh_token', refresh, path='/', domain=self.config.url)
100
- if access is None:
101
- self.refresh_auth()
117
+ # if access is None:
118
+ # self.refresh_auth()
102
119
 
103
120
  if _default_client is None or config.is_default:
104
121
  set_default_client(self)
105
122
 
106
- def request(self, method: str, url: str, params: dict = {}, files: dict[str, tuple[str, BufferedReader | bytes]] = {}):
123
+ def request(self, method: str, url: str, params: dict = {}, files: dict[str, tuple[str, BufferedReader | bytes]] = {}, level=AuthLevel.ACCESS):
107
124
  """Сделать запрос
108
125
 
109
126
  Args:
@@ -112,11 +129,18 @@ class Client:
112
129
  params (dict, optional): Параметры. Defaults to {}.
113
130
  files (dict[str, tuple[str, BufferedReader | bytes]], optional): Файлы. Defaults to {}.
114
131
  """
115
- l.debug('%s %s params=%s', method.upper(), url, params)
132
+ l.debug('%s %s params=%s authlevel=%s', method.upper(), url, params, level.value)
133
+
134
+ if level > self.auth_level and not self.config.bypass_auth_level:
135
+ raise InsufficientAuthLevelError(self.auth_level, level)
136
+
137
+ if level >= AuthLevel.ACCESS and self.access_token is None and url != 'v1/auth/refresh':
138
+ self.refresh_auth()
139
+
116
140
  def _fetch():
117
141
  return fetch(self, method, url, params, files)
118
142
 
119
- if not self.refresh_token:
143
+ if not self.refresh_token or url == 'v1/auth/refresh':
120
144
  return _fetch()
121
145
 
122
146
  try:
@@ -132,8 +156,6 @@ class Client:
132
156
  str: Токен
133
157
  """
134
158
  l.debug('refresh token')
135
- if not self.refresh_token:
136
- raise InsufficientAuthLevelError()
137
159
 
138
160
  res = refresh_token(self)
139
161
  res.raise_for_status()
@@ -1,6 +1,26 @@
1
1
  from enum import Enum
2
2
  from typing import Literal
3
3
 
4
+ class AuthLevel(Enum):
5
+ NO = 'no'
6
+ ACCESS = 'access'
7
+ REFRESH = 'refresh'
8
+ # LOGIN = 'login' # 🤫
9
+
10
+ def __gt__(self, other):
11
+ return _AUTH_LEVEL_HIERARCHY.index(self) > _AUTH_LEVEL_HIERARCHY.index(other)
12
+
13
+ def __lt__(self, other):
14
+ return _AUTH_LEVEL_HIERARCHY.index(self) < _AUTH_LEVEL_HIERARCHY.index(other)
15
+
16
+ def __ge__(self, other):
17
+ return _AUTH_LEVEL_HIERARCHY.index(self) >= _AUTH_LEVEL_HIERARCHY.index(other)
18
+
19
+ def __le__(self, other):
20
+ return _AUTH_LEVEL_HIERARCHY.index(self) <= _AUTH_LEVEL_HIERARCHY.index(other)
21
+
22
+ _AUTH_LEVEL_HIERARCHY = [AuthLevel.NO, AuthLevel.ACCESS, AuthLevel.REFRESH]
23
+
4
24
  class RateLimitMode(Enum):
5
25
  NO = 'no'
6
26
  MIN = 'min' # for one-time actions (eg script just to like post)
@@ -81,18 +101,18 @@ class AccessType(Enum):
81
101
  EVERYONE = 'everyone' # все
82
102
 
83
103
  def __gt__(self, other):
84
- return _HIERARCHY.index(self) > _HIERARCHY.index(other)
104
+ return _ACCESS_TYPE_HIERARCHY.index(self) > _ACCESS_TYPE_HIERARCHY.index(other)
85
105
 
86
106
  def __lt__(self, other):
87
- return _HIERARCHY.index(self) < _HIERARCHY.index(other)
107
+ return _ACCESS_TYPE_HIERARCHY.index(self) < _ACCESS_TYPE_HIERARCHY.index(other)
88
108
 
89
109
  def __ge__(self, other):
90
- return _HIERARCHY.index(self) >= _HIERARCHY.index(other)
110
+ return _ACCESS_TYPE_HIERARCHY.index(self) >= _ACCESS_TYPE_HIERARCHY.index(other)
91
111
 
92
112
  def __le__(self, other):
93
- return _HIERARCHY.index(self) <= _HIERARCHY.index(other)
113
+ return _ACCESS_TYPE_HIERARCHY.index(self) <= _ACCESS_TYPE_HIERARCHY.index(other)
94
114
 
95
- _HIERARCHY = [AccessType.EVERYONE, AccessType.FOLLOWERS, AccessType.MUTUAL, AccessType.EVERYONE] # 100% that hierarcy is spelled wrong
115
+ _ACCESS_TYPE_HIERARCHY = [AccessType.EVERYONE, AccessType.FOLLOWERS, AccessType.MUTUAL, AccessType.EVERYONE] # 100% that hierarcy is spelled wrong
96
116
 
97
117
 
98
118
  class SpanType(Enum):
@@ -2,6 +2,9 @@ from typing import Callable
2
2
 
3
3
  from requests import Response
4
4
 
5
+ from itd.enums import AuthLevel
6
+
7
+
5
8
  class ITDException(Exception):
6
9
  code: str | None = None # ['error']['code']
7
10
  message: str | None = None # ['error']['message']
@@ -110,8 +113,8 @@ class InvalidPasswordError(PasswordError, ValidateError):
110
113
  class NoRightsError(ITDException): pass
111
114
 
112
115
  class InsufficientAuthLevelError(NoRightsError):
113
- def __init__(self) :
114
- self.text = 'Insufficient auth level'
116
+ def __init__(self, current: AuthLevel, requires: AuthLevel) :
117
+ self.text = f'Insufficient auth level (current is {current.name}, requires {requires.name})'
115
118
 
116
119
  class PinNotOwnedError(NoRightsError):
117
120
  code = "PIN_NOT_OWNED"
@@ -253,4 +256,4 @@ class ProfileRequiredError(ITDException):
253
256
  text = 'No profile. Please create your profile first'
254
257
 
255
258
 
256
- DEFAULT_ERRORS = (RateLimitError(), InvalidAccessTokenError(), UnauthorizedError(), AccessTokenExpiredError(), AccountBannedError(), InternalError(), ProfileRequiredError())
259
+ DEFAULT_ERRORS = (RateLimitError(), InvalidAccessTokenError(), UnauthorizedError(), AccessTokenExpiredError(), AccountBannedError(), InternalError(), ProfileRequiredError(), RefreshTokenMissingError())
@@ -5,26 +5,37 @@ from time import sleep
5
5
 
6
6
  from pydantic import Field, BaseModel, field_validator
7
7
 
8
- from itd.base import ITDBaseModel, refresh_wrapper, ITDList
9
- from itd.client import Client
10
8
  from itd.comment import Comment, Comments
11
- from itd.enums import PostsTab, UserPostSorting, ReportReason, ReportTargetType, ParseMode, ALL
12
9
  from itd.file import PostAttach
13
10
  from itd.hashtag import Hashtag
14
11
  from itd.poll import Poll, NewPoll, PollOption
15
12
  from itd.report import Report
16
13
  from itd.span import Span
17
14
  from itd.user import User, _UserBase, Me
18
- from itd.utils import to_uuid, parse_datetime, format_attachments, ATTACHMENTS, parse_html, parse_md
15
+
19
16
  from itd.api.posts import (
20
17
  get_post, create_post, like_post, unlike_post, repost, view_post, pin_post, unpin_post,
21
18
  delete_post, restore_post, edit_post, get_posts, get_user_posts, get_liked_posts
22
19
  )
23
20
  from itd.api.hashtags import get_posts_by_hashtag
21
+ from itd.base import ITDBaseModel, refresh_wrapper, ITDList
22
+ from itd.client import Client
23
+ from itd.enums import PostsTab, UserPostSorting, ReportReason, ReportTargetType, ParseMode, ALL
24
24
  from itd.logger import get_logger
25
+ from itd.utils import to_uuid, parse_datetime, format_attachments, ATTACHMENTS, parse_html, parse_md
25
26
 
26
27
  l = get_logger('post')
27
28
 
29
+ # class ViewerSession:
30
+ # def __init__(self, payload: dict) -> None:
31
+ # self.p = self.post_id = UUID(payload['p'])
32
+ # self.u = self.user_id = UUID(payload['u'])
33
+ # self.e = self.expires_at = datetime.fromtimestamp(payload['e'])
34
+ # if isinstance(payload['s'], list):
35
+ # self.s = self.source = [ViewSource(source) for source in payload['s']]
36
+ # else:
37
+ # self.s = self.source = ViewSource(payload['s'])
38
+
28
39
 
29
40
  class Post(ITDBaseModel):
30
41
  _validator = lambda _: _PostValidate
@@ -58,6 +69,8 @@ class Post(ITDBaseModel):
58
69
 
59
70
  wall_recipient_id: UUID | None = Field(None, alias='wallRecipientId')
60
71
  wall_recipient: User | None = Field(None, alias='wallRecipient')
72
+ # vs: ViewerSession
73
+ vs: str # from 13.05 it is string token
61
74
 
62
75
 
63
76
  def __init__(self, id: str | UUID, client: Client | None = None) -> None:
@@ -370,6 +383,11 @@ class _PostValidate(BaseModel, Post): # BaseModel MUST be first or you ll have s
370
383
  if wall_recipient is not None:
371
384
  return User._from_dict(wall_recipient, False)
372
385
 
386
+ # @field_validator('vs', mode='plain')
387
+ # @classmethod
388
+ # def validate_vs(cls, vs: str):
389
+ # return ViewerSession(decode_jwt_payload(vs))
390
+
373
391
 
374
392
 
375
393
  class _BasePosts(ITDList[Post]):
@@ -14,7 +14,7 @@ version_provider = "uv"
14
14
 
15
15
  [project]
16
16
  name = "itd-sdk"
17
- version = "2.1.2"
17
+ version = "2.2.0"
18
18
  description = "ITD client for python"
19
19
  readme = "README.md"
20
20
  authors = [
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