itd-sdk 2.9.0__tar.gz → 2.9.2__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 (65) hide show
  1. {itd_sdk-2.9.0 → itd_sdk-2.9.2}/PKG-INFO +1 -1
  2. {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/__init__.py +6 -2
  3. {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/api/notifications.py +3 -2
  4. {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/api/search.py +3 -3
  5. {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/core/auth.py +119 -80
  6. {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/core/captcha/__init__.py +2 -2
  7. {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/core/client.py +23 -13
  8. {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/core/config.py +7 -1
  9. {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/core/profile.py +10 -7
  10. itd_sdk-2.9.2/itd/core/qr.py +169 -0
  11. {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/core/request.py +4 -2
  12. {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/core/utils.py +0 -16
  13. {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/enums.py +1 -1
  14. itd_sdk-2.9.2/itd/models/search.py +38 -0
  15. {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/models/utils.py +6 -5
  16. itd_sdk-2.9.2/itd/py.typed +0 -0
  17. {itd_sdk-2.9.0 → itd_sdk-2.9.2}/pyproject.toml +5 -1
  18. itd_sdk-2.9.0/itd/core/qr.py +0 -112
  19. itd_sdk-2.9.0/itd/models/__init__.py +0 -4
  20. {itd_sdk-2.9.0 → itd_sdk-2.9.2}/README.md +0 -0
  21. {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/api/__init__.py +0 -0
  22. {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/api/auth.py +0 -0
  23. {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/api/comments.py +0 -0
  24. {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/api/dwell.py +0 -0
  25. {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/api/files.py +0 -0
  26. {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/api/hashtags.py +0 -0
  27. {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/api/pins.py +0 -0
  28. {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/api/platform.py +0 -0
  29. {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/api/polls.py +0 -0
  30. {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/api/portal.py +0 -0
  31. {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/api/posts.py +0 -0
  32. {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/api/reports.py +0 -0
  33. {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/api/sessions.py +0 -0
  34. {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/api/subscription.py +0 -0
  35. {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/api/users.py +0 -0
  36. {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/api/verification.py +0 -0
  37. {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/core/__init__.py +0 -0
  38. {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/core/base.py +0 -0
  39. {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/core/captcha/base.py +0 -0
  40. {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/core/captcha/cloudflare.py +0 -0
  41. {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/core/captcha/itd.py +0 -0
  42. {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/core/cli.py +0 -0
  43. {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/core/default.py +0 -0
  44. {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/core/dwell.py +0 -0
  45. {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/core/limiter.py +0 -0
  46. {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/core/logger.py +0 -0
  47. {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/core/timer.py +0 -0
  48. {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/core/visibility.py +0 -0
  49. {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/exceptions.py +0 -0
  50. /itd_sdk-2.9.0/itd/py.typed → /itd_sdk-2.9.2/itd/models/__init__.py +0 -0
  51. {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/models/announcement.py +0 -0
  52. {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/models/clan.py +0 -0
  53. {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/models/comment.py +0 -0
  54. {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/models/file.py +0 -0
  55. {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/models/hashtag.py +0 -0
  56. {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/models/notification.py +0 -0
  57. {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/models/pin.py +0 -0
  58. {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/models/poll.py +0 -0
  59. {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/models/portal.py +0 -0
  60. {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/models/post.py +0 -0
  61. {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/models/report.py +0 -0
  62. {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/models/session.py +0 -0
  63. {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/models/span.py +0 -0
  64. {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/models/user.py +0 -0
  65. {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/models/version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: itd-sdk
3
- Version: 2.9.0
3
+ Version: 2.9.2
4
4
  Summary: ITD client for python
5
5
  Keywords: itd,itdsocial
6
6
  Author: fi-res
@@ -37,7 +37,7 @@ from time import sleep
37
37
  __version__ = version('itd-sdk')
38
38
 
39
39
  from itd.core.client import Client as ITDClient
40
- from itd.core.client import init_client
40
+ from itd.core.client import init_client, init_not_authed_client
41
41
  from itd.core.config import Config as ITDConfig
42
42
  from itd.core.default import LimiterConfig, limiters
43
43
  from itd.core.default import set_config as set_limiter_config
@@ -51,8 +51,9 @@ from itd.models.notification import Notification, Notifications, Ntf, Ntfs
51
51
  from itd.models.poll import NewPoll
52
52
  from itd.models.portal import Portal
53
53
  from itd.models.post import HashtagPosts, LikedPosts, Post, Posts, UserPosts
54
+ from itd.models.search import Search
54
55
  from itd.models.session import Sessions
55
- from itd.models.user import Me, User, Users, get_follow_status
56
+ from itd.models.user import Me, User, Users, WhoToFollow, get_follow_status
56
57
  from itd.models.version import Apps, Changelog
57
58
 
58
59
 
@@ -71,6 +72,7 @@ __all__ = [
71
72
  'ITDClient',
72
73
  'ITDConfig',
73
74
  'init_client',
75
+ 'init_not_authed_client',
74
76
  'Comment',
75
77
  'Clan',
76
78
  'TopClans',
@@ -94,6 +96,8 @@ __all__ = [
94
96
  'Me',
95
97
  'Users',
96
98
  'Portal',
99
+ 'WhoToFollow',
100
+ 'Search',
97
101
  'get_follow_status',
98
102
  'set_limiter_config',
99
103
  'LimiterConfig',
@@ -15,8 +15,9 @@ def get_notifications(client: Client, limit: int = 20, offset: int = 0):
15
15
  return {'limit': limit, 'offset': offset}
16
16
 
17
17
 
18
- @endpoint('post', 'notifications/{id}/read', NotFoundError('Notification', json_check=lambda json: json.get('success') is False))
19
- def mark_as_read(client: Client, id: UUID): ...
18
+ @endpoint('post', 'notifications/read-batch', NotFoundError('Notification', json_check=lambda json: json.get('success') is False))
19
+ def mark_as_read(client: Client, id: UUID):
20
+ return {'ids': [str(id)]}
20
21
 
21
22
 
22
23
  @endpoint('post', 'notifications/read-all')
@@ -2,14 +2,14 @@ from __future__ import annotations
2
2
 
3
3
  from typing import TYPE_CHECKING
4
4
 
5
+ from itd.core.request import endpoint
5
6
  from itd.enums import AuthLevel
6
7
  from itd.exceptions import ValidationError
7
- from itd.core.request import endpoint
8
8
 
9
9
  if TYPE_CHECKING:
10
10
  from itd.core.client import Client
11
11
 
12
12
 
13
13
  @endpoint('get', 'search', ValidationError(), level=AuthLevel.NO)
14
- def search(client: Client, query: str, user_limit: int = 5, hashtag_limit: int = 5):
15
- return {'userLimit': user_limit, 'hashtagLimit': hashtag_limit, 'q': query}
14
+ def search(client: Client, query: str, users_limit: int = 10, hashtags_limit: int = 10):
15
+ return {'userLimit': users_limit, 'hashtagLimit': hashtags_limit, 'q': query}
@@ -1,10 +1,12 @@
1
+ from contextlib import contextmanager
2
+ from dataclasses import dataclass
1
3
  from os import getenv
2
4
  from typing import TYPE_CHECKING
3
5
 
4
6
  from itd.api.auth import sign_in
5
7
  from itd.core.captcha import get_turnstile
6
8
  from itd.core.logger import RICH_AVAILABLE, get_logger, iprint, rich_input
7
- from itd.core.qr import auth_qr
9
+ from itd.core.qr import interactive_auth_qr
8
10
  from itd.core.utils import shorten_token
9
11
  from itd.exceptions import (
10
12
  AccessTokenExpiredError,
@@ -28,17 +30,25 @@ if RICH_AVAILABLE:
28
30
  l = get_logger('auth')
29
31
 
30
32
 
31
- def _auth_login(client: 'Client', email: str, password: str):
32
- client._profile.email = email
33
- client._profile.password = password
34
- client._profile.creds_valid = True
35
- client._set_from_profile()
33
+ @contextmanager
34
+ def _status(text: str):
35
+ if not RICH_AVAILABLE:
36
+ iprint(l, text)
37
+ yield None
38
+ return
39
+ with Status(text) as status:
40
+ yield status
36
41
 
37
- def _sign_in(turnstile):
42
+
43
+ def _auth_login(client: 'Client', email: str, password: str) -> bool:
44
+ with _status('Solving captcha..') as status:
45
+ turnstile = get_turnstile(client, status=status)
46
+ if status:
47
+ status.update('Verifying..')
38
48
  try:
39
- res = sign_in(client, client._profile.email, client._profile.password, 'turnstileToken', turnstile)
49
+ apply_auth(client, CredentialsAuth(email, password, turnstile))
40
50
  except CaptchaFailedError:
41
- l.error('captcha verify failed. Please fill issue at https://github.com/itd-sdk/itd-sdk/issues/new')
51
+ l.error('captcha verify failed; please fill issue at https://github.com/itd-sdk/itd-sdk/issues/new')
42
52
  except InvalidCredentialsError:
43
53
  l.error('invalid email or password')
44
54
  except EmailDomainNotAllowedError:
@@ -46,34 +56,15 @@ def _auth_login(client: 'Client', email: str, password: str):
46
56
  except InvalidEmailError:
47
57
  l.error('invalid email format')
48
58
  else:
49
- client._profile.set_refresh(res.cookies['refresh_token'], set_expire=True)
50
- client._profile.set_access(res.json()['accessToken'])
51
- client._profile.creds_valid = True
52
- return True
53
-
54
- if RICH_AVAILABLE:
55
- with Status('Solving captcha..') as status:
56
- turnstile = get_turnstile(client, status=status)
57
- status.update('Verifying..')
58
- if _sign_in(turnstile):
59
- iprint(l, 'accepted')
60
- return True
61
- else:
62
- iprint(l, 'solving captcha..')
63
- if _sign_in(get_turnstile(client)):
64
59
  iprint(l, 'accepted')
65
60
  return True
61
+ return False
66
62
 
67
- client._profile.creds_valid = False
68
-
69
-
70
- def _auth_refresh(client: 'Client', refresh: str):
71
- client._profile.set_refresh(refresh, set_expire=True)
72
- client._set_from_profile()
73
63
 
74
- def _verify():
64
+ def _auth_refresh(client: 'Client', refresh: str) -> bool:
65
+ with _status('Verifying..'):
75
66
  try:
76
- client.refresh_auth()
67
+ apply_auth(client, RefreshAuth(refresh))
77
68
  except SessionExpiredError:
78
69
  l.error('refresh token expired')
79
70
  except SessionNotFoundError:
@@ -81,28 +72,15 @@ def _auth_refresh(client: 'Client', refresh: str):
81
72
  except SessionRevokedError:
82
73
  l.error('refresh token revoked')
83
74
  else:
84
- return True
85
-
86
- if RICH_AVAILABLE:
87
- with Status('Verifying..'):
88
- if _verify():
89
- iprint(l, 'accepted')
90
- return True
91
- else:
92
- if _verify():
93
75
  iprint(l, 'accepted')
94
76
  return True
77
+ return False
95
78
 
96
- client._profile.refresh_valid = False
97
-
98
-
99
- def _auth_access(client: 'Client', access: str):
100
- client._profile.set_access(access)
101
- client._set_from_profile()
102
79
 
103
- def _verify():
80
+ def _auth_access(client: 'Client', access: str) -> bool:
81
+ with _status('Verifying..'):
104
82
  try:
105
- client.user.refresh()
83
+ apply_auth(client, AccessAuth(access))
106
84
  except AccessTokenExpiredError:
107
85
  l.error('access token expired')
108
86
  except InvalidAccessTokenError:
@@ -110,19 +88,95 @@ def _auth_access(client: 'Client', access: str):
110
88
  except JWTAlgorithmUnsupportedError:
111
89
  l.error('jwt algorithm unsupported')
112
90
  else:
113
- return True
114
-
115
- if RICH_AVAILABLE:
116
- with Status('Verifying..'):
117
- if _verify():
118
- iprint(l, 'accepted')
119
- return True
120
- else:
121
- if _verify():
122
91
  iprint(l, 'accepted')
123
92
  return True
93
+ return False
94
+
95
+
96
+ @dataclass
97
+ class CredentialsAuth:
98
+ email: str
99
+ password: str
100
+ turnstile: str | None = None
124
101
 
125
- client._profile.access_valid = False
102
+
103
+ @dataclass
104
+ class RefreshAuth:
105
+ refresh: str
106
+ access: str | None = None
107
+
108
+
109
+ @dataclass
110
+ class AccessAuth:
111
+ access: str
112
+
113
+
114
+ AuthMethod = CredentialsAuth | RefreshAuth | AccessAuth
115
+
116
+
117
+ def apply_auth(client: 'Client', auth: AuthMethod | None) -> None:
118
+ match auth:
119
+ case CredentialsAuth():
120
+ client._profile.email = auth.email
121
+ client._profile.password = auth.password
122
+ client._profile.creds_valid = True
123
+ client._set_from_profile()
124
+ try:
125
+ res = sign_in(client, auth.email, auth.password, 'turnstileToken', auth.turnstile or get_turnstile(client)[1])
126
+ except Exception:
127
+ client._profile.email = None
128
+ client._profile.password = None
129
+ client._profile.creds_valid = False
130
+ client._set_from_profile()
131
+ raise
132
+ client._profile.set_refresh(res.cookies['refresh_token'], set_expire=True)
133
+ client._profile.set_access(res.json()['accessToken'])
134
+
135
+ case RefreshAuth():
136
+ client._profile.set_refresh(auth.refresh, set_expire=True)
137
+ if auth.access:
138
+ client._profile.set_access(auth.access)
139
+ client._set_from_profile()
140
+ try:
141
+ client.refresh_auth()
142
+ except Exception:
143
+ client._profile.refresh_valid = False
144
+ client._set_from_profile()
145
+ raise
146
+
147
+ case AccessAuth():
148
+ client._profile.set_access(auth.access)
149
+ client._set_from_profile()
150
+ try:
151
+ client.user.refresh()
152
+ except Exception:
153
+ client._profile.access_valid = False
154
+ client._set_from_profile()
155
+ raise
156
+
157
+ client._profile.flush()
158
+
159
+
160
+ def env_auth(client: 'Client') -> bool:
161
+ method = getenv('ITD_AUTH_METHOD')
162
+ if not method:
163
+ return False
164
+
165
+ match method:
166
+ case 'login':
167
+ auth = CredentialsAuth(getenv('ITD_LOGIN', ''), getenv('ITD_PASSWORD', ''))
168
+ case 'refresh':
169
+ auth = RefreshAuth(getenv('ITD_REFRESH', ''))
170
+ case 'access':
171
+ auth = AccessAuth(getenv('ITD_ACCESS', ''))
172
+ case 'no':
173
+ auth = None
174
+ case _:
175
+ raise ValueError(f'unknown env auth method {method}')
176
+
177
+ apply_auth(client, auth)
178
+ l.info('authorized env method=%s', method)
179
+ return True
126
180
 
127
181
 
128
182
  def interactive_auth(client: 'Client') -> bool:
@@ -141,24 +195,6 @@ def interactive_auth(client: 'Client') -> bool:
141
195
  return True
142
196
  client._credtest = True
143
197
 
144
- if getenv('ITD_AUTH_METHOD'):
145
- match getenv('ITD_AUTH_METHOD'):
146
- case 'login':
147
- if _auth_login(client, getenv('ITD_LOGIN', ''), getenv('ITD_PASSWORD', '')):
148
- return True
149
- case 'refresh':
150
- if _auth_refresh(client, getenv('ITD_REFRESH', '')):
151
- return True
152
- case 'access':
153
- if _auth_access(client, getenv('ITD_ACCESS', '')):
154
- return True
155
- case _:
156
- l.error('unknown option')
157
- quit()
158
-
159
- l.error('auth failed')
160
- quit()
161
-
162
198
  if not client._profile.refresh_valid and client._profile.refresh:
163
199
  l.warning('session file refresh token is not valid')
164
200
 
@@ -173,7 +209,8 @@ def interactive_auth(client: 'Client') -> bool:
173
209
  iprint(l, '[2] Login via QR code')
174
210
  iprint(l, '[3] Manually auth using refresh token')
175
211
  iprint(l, '[4] Manually auth using access token')
176
- iprint(l, '[5] Quit')
212
+ iprint(l, '[5] Init client with authorization')
213
+ iprint(l, '[6] Quit')
177
214
 
178
215
  while True:
179
216
  option = rich_input('option', 'magenta')
@@ -183,7 +220,7 @@ def interactive_auth(client: 'Client') -> bool:
183
220
  if _auth_login(client, rich_input('email', 'green'), rich_input('password', 'green', password=True)):
184
221
  return True
185
222
  case '2':
186
- if auth_qr(client):
223
+ if interactive_auth_qr(client):
187
224
  return True
188
225
  case '3':
189
226
  if _auth_refresh(client, rich_input('refresh token', 'cyan', password=True)):
@@ -192,7 +229,9 @@ def interactive_auth(client: 'Client') -> bool:
192
229
  l.info('note: authorization will work only for ~15min')
193
230
  if _auth_access(client, rich_input('access token', 'cyan')):
194
231
  return True
195
- case '5' | 'q' | 'quit':
232
+ case '5':
233
+ return True
234
+ case '6' | 'q' | 'quit':
196
235
  quit()
197
236
  case _:
198
237
  l.error('unknown option')
@@ -19,7 +19,7 @@ def get_turnstile(client: 'Client | None' = None, status=None) -> str: # tuple[
19
19
  # raise RuntimeError(f'Unknown provider: {provider_data["provider"]}')
20
20
 
21
21
  # provider = providers[provider_data['provider']]()
22
- if client and client.config.captcha_solve:
22
+ if client and not client.config.captcha_solve:
23
23
  l.warning('captcha solving disabled')
24
24
  iprint(l, 'solve captcha on other devices via `uv run itd captcha`, then paste result here')
25
25
  iprint(l, 'enter `continue` to force solve captcha on this device')
@@ -32,7 +32,7 @@ def get_turnstile(client: 'Client | None' = None, status=None) -> str: # tuple[
32
32
  status.start()
33
33
 
34
34
  if not CAPTCHA_AVAILABLE:
35
- l.error('captcha libraries not installed; install via `uv add itd-sdk[captcha]`')
35
+ l.error(r'captcha libraries not installed; install via `uv add itd-sdk\[captcha]`')
36
36
  provider = CloudflareProvider(False if not client else client.config.captcha_headless)
37
37
  provider.launch()
38
38
  turnstile = provider.solve()
@@ -12,7 +12,7 @@ from requests import Response, Session
12
12
  from requests.adapters import HTTPAdapter
13
13
 
14
14
  from itd.api.auth import change_password, logout, refresh_token, sign_in
15
- from itd.core.auth import interactive_auth
15
+ from itd.core.auth import AuthMethod, apply_auth, env_auth, interactive_auth
16
16
  from itd.core.captcha import get_turnstile
17
17
  from itd.core.config import Config
18
18
  from itd.core.default import maybe_get_default_client, set_default_client
@@ -20,7 +20,6 @@ from itd.core.dwell import DwellTracker
20
20
  from itd.core.logger import get_logger
21
21
  from itd.core.profile import Profile, clear_anon_profile
22
22
  from itd.core.request import fetch
23
- from itd.core.utils import get_profile
24
23
  from itd.core.visibility import VisibilityTracker
25
24
  from itd.enums import AuthLevel
26
25
  from itd.exceptions import AccessTokenExpiredError, InsufficientAuthLevelError, SessionExpiredError
@@ -33,26 +32,31 @@ l = get_logger('client')
33
32
 
34
33
 
35
34
  class Client:
36
- def __init__(self, name: str, config: Config | None = None):
35
+ def __init__(self, name: str, config: Config | None = None, auth: AuthMethod | None = None):
37
36
  l.info('init client %s', name)
38
37
  self.config = config or Config()
39
38
 
40
- self.auth_level: AuthLevel = AuthLevel.NO
41
- self._profile: Profile = get_profile(name)
42
-
43
- self._refresh_lock = RLock() # so background timers and main thread dont refresh token simultaneously # еба он мой стиль коментов спиздил
44
-
45
39
  self.session = Session()
46
40
  adapter = HTTPAdapter(pool_connections=1, pool_maxsize=10, pool_block=False) # idk what is this, (claude added) just for better stability
47
41
  self.session.mount('https://', adapter)
48
42
 
43
+ self.auth_level: AuthLevel = AuthLevel.NO
44
+ self._profile: Profile = Profile.get(name)
45
+ self._set_from_profile()
46
+
47
+ self._refresh_lock = RLock() # so background timers and main thread dont refresh token simultaneously # еба он мой стиль коментов спиздил
48
+
49
49
  if maybe_get_default_client() is None or self.config.is_default:
50
50
  set_default_client(self)
51
51
 
52
- interactive_auth(self)
53
- self._set_from_profile()
52
+ self._credtest = True
53
+ if auth is not None:
54
+ apply_auth(self, auth)
55
+ if name != 'no-auth' and self.auth_level == AuthLevel.NO:
56
+ if not env_auth(self) and self.config._interactive_auth:
57
+ interactive_auth(self)
54
58
  self._profile.flush()
55
- self._credtest = False # skip all checks and refreshing to test credentials
59
+ self._credtest = False
56
60
 
57
61
  self.dwell_tracker = DwellTracker(self)
58
62
  self.dwell_tracker.start()
@@ -132,6 +136,7 @@ class Client:
132
136
 
133
137
  if level >= AuthLevel.REFRESH and ((self._profile.refresh and self._profile.is_refresh_expired) or not self._profile.refresh_valid):
134
138
  self._profile.refresh_valid = False
139
+ self._profile.flush()
135
140
  if self.auth_level == AuthLevel.LOGIN:
136
141
  self.login()
137
142
  else:
@@ -145,6 +150,7 @@ class Client:
145
150
  and ((self._profile.access_data and self._profile.access_data.is_expired) or not self._profile.access_valid)
146
151
  ):
147
152
  self._profile.access_valid = False
153
+ self._profile.flush()
148
154
  if self.auth_level >= AuthLevel.REFRESH:
149
155
  self.refresh_auth()
150
156
  else:
@@ -209,8 +215,12 @@ class Client:
209
215
  change_password(self, old, new)
210
216
 
211
217
 
212
- def init_client(name: str | None = None, config: Config = Config()) -> Client:
218
+ def init_client(name: str | None = None, config: Config | None = None, auth: AuthMethod | None = None) -> Client:
213
219
  if name == 'anon':
214
220
  clear_anon_profile()
215
221
  register(clear_anon_profile)
216
- return Client(name or 'default', config=config)
222
+ return Client(name or 'default', config=config, auth=auth)
223
+
224
+
225
+ def init_not_authed_client(config: Config | None = None):
226
+ return Client('no-auth', config=config)
@@ -114,7 +114,8 @@ class Config:
114
114
  on_exceptions: dict[type[Exception], Callable[[Exception], None]] = field(default_factory=dict)
115
115
  batch_sizes: BatchSizes = field(default_factory=BatchSizes)
116
116
  refresh_token_cookie_name: str = 'refresh_token'
117
- token_expiry_margin: float = 60 # how many seconds before expiration access token is considered expired (margin for network and clock skew)
117
+
118
+ interactive_auth: bool | None = None
118
119
 
119
120
  def __post_init__(self):
120
121
  match self.user_agent:
@@ -162,3 +163,8 @@ class Config:
162
163
  self._retry_exceptions = ()
163
164
  else:
164
165
  self._retry_exceptions = self.retry_exceptions
166
+
167
+ if self.interactive_auth is None:
168
+ self._interactive_auth = self.client_type != 'client'
169
+ else:
170
+ self._interactive_auth = self.interactive_auth
@@ -51,16 +51,18 @@ class Profile(BaseModel):
51
51
  def flush(self):
52
52
  assert self._file
53
53
  self.updated_at = datetime.now()
54
+ if self._file.name == 'no-auth':
55
+ return
54
56
  self._file.write_text(dumps(self.model_dump(mode='json')))
55
57
 
56
58
  def update(self):
57
59
  assert self._file
60
+ if self._file.name == 'no-auth':
61
+ return False
58
62
  new = self.model_validate(loads(self._file.read_text()))
59
- if self.updated_at <= new.updated_at:
60
- for field in Profile.model_fields:
61
- setattr(self, field, getattr(new, field))
62
- return True
63
- return False
63
+ for field in Profile.model_fields:
64
+ setattr(self, field, getattr(new, field))
65
+ return True
64
66
 
65
67
  @property
66
68
  def is_refresh_expired(self) -> bool:
@@ -88,9 +90,10 @@ class Profile(BaseModel):
88
90
  @classmethod
89
91
  def get(cls, name: str):
90
92
  file = user_data_path('itd_sdk', False, ensure_exists=True) / f'{name}.json'
91
- instance = cls(_file=file)
93
+ instance = cls()
94
+ instance._file = file
92
95
  if not file.exists():
93
- instance = cls(_file=file)
96
+ instance.flush()
94
97
  else:
95
98
  instance.update()
96
99
 
@@ -0,0 +1,169 @@
1
+ from json import loads
2
+ from time import sleep
3
+ from typing import TYPE_CHECKING, Iterator
4
+ from uuid import UUID
5
+
6
+ from pydantic import BaseModel, Field
7
+ from sseclient import SSEClient
8
+
9
+ from itd.api.auth import qr_claim, qr_start, qr_stream
10
+ from itd.core.logger import RICH_AVAILABLE, get_logger, iprint
11
+
12
+ if TYPE_CHECKING:
13
+ from itd.core.client import Client
14
+ from itd.core.config import Config
15
+
16
+ try:
17
+ from qrcode import QRCode
18
+
19
+ QR_AVAILABLE = True
20
+ except ImportError:
21
+ QR_AVAILABLE = False
22
+
23
+ try:
24
+ from rich.status import Status
25
+ except ImportError:
26
+ pass
27
+
28
+ l = get_logger('qr')
29
+
30
+
31
+ class ITDQRCode(BaseModel):
32
+ id: UUID = Field(alias='qrId')
33
+ payload: str
34
+ claim_token: str = Field(alias='claimToken')
35
+ expires_in: int = Field(90, alias='expiresIn')
36
+
37
+
38
+ class QRLogin:
39
+ def __init__(self):
40
+ from itd import init_not_authed_client
41
+
42
+ self.client = init_not_authed_client()
43
+ self.stream = None
44
+ self.qr = None
45
+ self.auth = None
46
+ self.status = 'pending'
47
+ self.refresh()
48
+
49
+ def refresh(self) -> ITDQRCode:
50
+ self.close()
51
+ self.qr = ITDQRCode.model_validate(qr_start(self.client).json())
52
+ l.debug('qr code: id=%s claim_token=%s', self.qr.id, self.qr.claim_token)
53
+ return self.qr
54
+
55
+ def events(self) -> Iterator[str]:
56
+ assert self.qr
57
+ self.stream = qr_stream(self.client, qr_id=self.qr.id, claim_token=self.qr.claim_token)
58
+ l.debug('start stream')
59
+ try:
60
+ for event in SSEClient(self.stream).events():
61
+ self.status = loads(event.data)['status']
62
+ l.debug('qr code status: %s', self.status)
63
+ yield self.status
64
+ if self.status in ('approved', 'rejected'):
65
+ break
66
+
67
+ if self.status == 'approved':
68
+ self.claim()
69
+ self.status = 'authorized'
70
+ yield 'authorized'
71
+ elif self.status != 'rejected':
72
+ l.debug('expire qr')
73
+ self.status = 'expired'
74
+ yield 'expired'
75
+
76
+ finally:
77
+ self.close()
78
+ l.debug('stop stream')
79
+
80
+ def claim(self):
81
+ from itd.core.auth import RefreshAuth
82
+
83
+ assert self.qr
84
+ res = qr_claim(self.client, qr_id=self.qr.id, claim_token=self.qr.claim_token)
85
+ self.auth = RefreshAuth(res.cookies['refresh_token'], res.json()['accessToken'])
86
+
87
+ def get_client(self, name: str | None = None, config: 'Config | None' = None):
88
+ from itd import ITDConfig, init_client
89
+
90
+ if config is None:
91
+ config = ITDConfig()
92
+ config.is_default = True
93
+
94
+ assert self.auth
95
+ return init_client(name, config, auth=self.auth)
96
+
97
+ def close(self):
98
+ if self.stream:
99
+ self.stream.close()
100
+ self.stream = None
101
+
102
+ def __enter__(self) -> 'QRLogin':
103
+ return self
104
+
105
+ def __exit__(self, *exc) -> None:
106
+ self.close()
107
+
108
+
109
+ def interactive_auth_qr(client: 'Client'):
110
+ if not QR_AVAILABLE:
111
+ l.error(r'qrcode library not installed; install via `uv add itd-sdk\[qrcode]`')
112
+ return False
113
+
114
+ if RICH_AVAILABLE:
115
+ status = Status('Waiting for scan')
116
+ status.start()
117
+ else:
118
+ status = None
119
+
120
+ with QRLogin() as qr:
121
+ try:
122
+ for _ in range(5):
123
+ qr.refresh()
124
+ if status:
125
+ status.update('Generating QR')
126
+ assert qr.qr
127
+ ascii_qr = QRCode(border=2)
128
+ ascii_qr.add_data(qr.qr.payload)
129
+ iprint(l, 'scan QR code with mobile app:')
130
+ ascii_qr.print_ascii(invert=True)
131
+ if status:
132
+ status.update('Waiting for scan')
133
+
134
+ for event in qr.events():
135
+ if event == 'approved':
136
+ iprint(l, 'qr code approved')
137
+ assert qr.auth
138
+ assert qr.auth.access
139
+ client._profile.set_access(qr.auth.access)
140
+ client._profile.set_refresh(qr.auth.refresh, set_expire=True)
141
+ return True
142
+
143
+ elif event == 'rejected':
144
+ l.error('qr code rejected')
145
+ break
146
+
147
+ elif event == 'scanned':
148
+ iprint(l, 'qr code scanned')
149
+ if status:
150
+ status.update('Waiting for accept')
151
+
152
+ elif event == 'pending':
153
+ iprint(l, 'qr code pending')
154
+
155
+ # elif res['status'] == 'captcha_required':
156
+ # iprint(l, 'captcha required')
157
+ # if status:
158
+ # status.update('Solving captcha')
159
+ # turnstile = get_turnstile(client)
160
+
161
+ sleep(5)
162
+
163
+ l.error('all retries to auth qr exceeded')
164
+ return False
165
+ finally:
166
+ if status:
167
+ status.stop()
168
+ if qr.stream:
169
+ qr.stream.close()
@@ -241,6 +241,7 @@ def api_wrapper(*exceptions: ITDException):
241
241
  # token is checked before the request, but server still can reject it (clock skew, revoked session) - refresh and repeat the request once, before callbacks
242
242
  if isinstance(exception, (AccessTokenExpiredError, InvalidAccessTokenError)) and not access_reauthed and not client._credtest:
243
243
  client._profile.access_valid = False
244
+ client._profile.flush()
244
245
  if name != 'refresh_token':
245
246
  access_reauthed = True
246
247
  l.warning('%s on %s: refresh access_token and retry', exception.__class__.__name__, name)
@@ -252,9 +253,10 @@ def api_wrapper(*exceptions: ITDException):
252
253
  and not refresh_reauthed
253
254
  and not client._credtest
254
255
  ):
255
- refresh_reauthed = True
256
+ client._profile.refresh_valid = False
257
+ client._profile.flush()
256
258
  if name != 'sign_in':
257
- client._profile.refresh_valid = False
259
+ refresh_reauthed = True
258
260
  l.warning('%s on %s: refresh refresh_token and retry', exception.__class__.__name__, name)
259
261
  client.login()
260
262
  return exec()
@@ -1,12 +1,7 @@
1
1
  from datetime import datetime
2
- from json import dumps, loads
3
2
  from sys import version
4
3
  from uuid import UUID
5
4
 
6
- from platformdirs import user_data_path
7
-
8
- from itd.core.profile import Profile
9
-
10
5
 
11
6
  def get_sdk_user_agent():
12
7
  from itd import __version__ # i fucking hate circular imports this is sooo stupid
@@ -14,17 +9,6 @@ def get_sdk_user_agent():
14
9
  return f'itd-sdk/{__version__} (Python/{version})'
15
10
 
16
11
 
17
- def get_profile(name: str) -> Profile:
18
- file = user_data_path('itd_sdk', False, ensure_exists=True) / f'{name}.json'
19
- if not file.exists():
20
- model = Profile(_file=file)
21
- file.write_text(dumps(model.model_dump(mode='json')))
22
- else:
23
- model = Profile.model_validate(loads(file.read_text()))
24
- model._file = file
25
- return model
26
-
27
-
28
12
  def shorten_token(value: str | None, max_len: int = 20):
29
13
  if value is None:
30
14
  return None
@@ -51,7 +51,7 @@ class AuthLevel(Enum):
51
51
  NO = 'no'
52
52
  ACCESS = 'access'
53
53
  REFRESH = 'refresh'
54
- LOGIN = 'login'
54
+ LOGIN = 'login' # todo rename to creds
55
55
 
56
56
  def __gt__(self, other):
57
57
  return _AUTH_LEVEL_HIERARCHY.index(self) > _AUTH_LEVEL_HIERARCHY.index(other)
@@ -0,0 +1,38 @@
1
+ from typing import TYPE_CHECKING
2
+
3
+ from itd.api.search import search
4
+ from itd.core.base import ITDBaseModel
5
+ from itd.models.hashtag import Hashtag
6
+ from itd.models.user import User
7
+
8
+ if TYPE_CHECKING:
9
+ from itd.core.client import Client
10
+
11
+
12
+ class Search(ITDBaseModel):
13
+ _refreshable = False
14
+ users: list[User]
15
+ hashtags: list[Hashtag]
16
+
17
+ def __init__(self, query: str, users_limit: int = 20, hashtags_limit: int = 20, *, client: 'Client | None' = None):
18
+ super().__init__(client=client)
19
+ self.query = query
20
+ self.users_limit = users_limit
21
+ self.hashtags_limit = hashtags_limit
22
+ self.refresh()
23
+
24
+ def refresh(self, client: 'Client | None' = None):
25
+ client = client or self.client
26
+ res = search(client, self.query, self.users_limit, self.hashtags_limit).json()['data']
27
+ self.users = [User.from_dict(user, client=client) for user in res['users']]
28
+ self.hashtags = [Hashtag.from_dict(hashtags, client=client) for hashtags in res['hashtags']]
29
+
30
+ def __iter__(self):
31
+ yield from self.users
32
+ yield from self.hashtags
33
+
34
+ def __len__(self) -> int:
35
+ return len(self.users) + len(self.hashtags)
36
+
37
+ def __bool__(self) -> bool:
38
+ return bool(self.users or self.hashtags)
@@ -58,10 +58,11 @@ def parse_html(text: str) -> tuple[str, list[Span]]:
58
58
  """Спарсить HTML
59
59
 
60
60
  Поддерживаемые теги:
61
- - <b>, <i>, <s>, <u>, <code>, <spoiler>, <q>, <a href="url">text</a>, <a>url</a>
61
+ - `<b>, <i>, <s>, <u>, <code>, <spoiler>, <q>`
62
+ - `<a href="url">text</a>` или `<а>url</а>` (ссылки)
62
63
 
63
64
  Args:
64
- text: HTML-строка для парсинга
65
+ text (str): HTML-строка для парсинга
65
66
 
66
67
  Returns:
67
68
  str: чистая строка
@@ -84,18 +85,18 @@ def parse_html(text: str) -> tuple[str, list[Span]]:
84
85
  return root.text_content(), spans
85
86
 
86
87
 
87
- def parse_md(md: str) -> tuple[str, list[Span]]:
88
+ def parse_md(text: str) -> tuple[str, list[Span]]:
88
89
  """Спарсить markdown
89
90
 
90
91
  Поддерживаемые теги:
91
92
  - *, _, **, __, ~, ~~, `, ||, \\[text](url)
92
93
 
93
94
  Args:
94
- text: строка для парсинга
95
+ text (str): строка для парсинга
95
96
 
96
97
  Returns:
97
98
  str: чистая строка
98
99
  list[Span]: список спанов
99
100
  """
100
- text, spans = convert(md, latex_escape=False)
101
+ text, spans = convert(text, latex_escape=False)
101
102
  return text, [Span.model_validate(span, from_attributes=True) for span in spans]
File without changes
@@ -14,7 +14,7 @@ version_provider = "uv"
14
14
 
15
15
  [project]
16
16
  name = "itd-sdk"
17
- version = "2.9.0"
17
+ version = "2.9.2"
18
18
  description = "ITD client for python"
19
19
  readme = "README.md"
20
20
  authors = [
@@ -66,6 +66,10 @@ captcha = [
66
66
  dev = [
67
67
  "pytest>=8.4.2",
68
68
  "python-dotenv>=1.2.1",
69
+ "griffe-typingdoc>=0.3.0",
70
+ "mkdocs>=1.6.1",
71
+ "mkdocs-material>=9.7.6",
72
+ "mkdocstrings[python]>=0.30.1"
69
73
  ]
70
74
 
71
75
  [project.scripts]
@@ -1,112 +0,0 @@
1
- from json import loads
2
- from time import sleep
3
- from typing import TYPE_CHECKING
4
- from uuid import UUID
5
-
6
- from pydantic import BaseModel, Field
7
- from sseclient import SSEClient
8
-
9
- from itd.api.auth import qr_claim, qr_start, qr_stream
10
- from itd.core.logger import RICH_AVAILABLE, get_logger, iprint
11
-
12
- if TYPE_CHECKING:
13
- from itd.core.client import Client
14
-
15
- try:
16
- from qrcode import QRCode
17
-
18
- QR_AVAILABLE = True
19
- except ImportError:
20
- QR_AVAILABLE = False
21
-
22
- try:
23
- from rich.status import Status
24
- except ImportError:
25
- pass
26
-
27
- l = get_logger('qr')
28
-
29
-
30
- class ITDQRCode(BaseModel):
31
- id: UUID = Field(alias='qrId')
32
- payload: str
33
- claim_token: str = Field(alias='claimToken')
34
- expires_in: int = Field(90, alias='expiresIn')
35
-
36
-
37
- def auth_qr(client: 'Client'):
38
- if not QR_AVAILABLE:
39
- l.error('qrcode library not installed; install via `uv add itd-sdk[qrcode]`')
40
- return False
41
-
42
- itd_qr = None
43
-
44
- def generate_qr():
45
- nonlocal itd_qr
46
- if status:
47
- status.update('Generating QR')
48
- itd_qr = ITDQRCode.model_validate(qr_start(client).json())
49
- l.debug('qr code: id=%s claim_token=%s', itd_qr.id, itd_qr.claim_token)
50
- qr = QRCode(border=0)
51
- qr.add_data(itd_qr.payload)
52
- iprint(l, 'scan QR code with mobile app:')
53
- qr.print_ascii(invert=False)
54
- if status:
55
- status.update('Waiting for scan')
56
-
57
- if RICH_AVAILABLE:
58
- status = Status('Waiting for scan')
59
- status.start()
60
- else:
61
- status = None
62
- stream = None
63
-
64
- try:
65
- for _ in range(5):
66
- generate_qr()
67
- assert itd_qr
68
-
69
- stream = qr_stream(client, qr_id=itd_qr.id, claim_token=itd_qr.claim_token)
70
- l.info('start stream')
71
- for event in SSEClient(stream).events():
72
- res = loads(event.data)
73
- l.debug('qr code status: %s', res['status'])
74
-
75
- if res['status'] == 'approved':
76
- iprint(l, 'qr code approved')
77
- claim_res = qr_claim(client, qr_id=itd_qr.id, claim_token=itd_qr.claim_token)
78
- client._profile.set_refresh(claim_res.cookies['refresh_token'])
79
- client._profile.set_access(claim_res.json()['accessToken'])
80
- stream.close()
81
- return True
82
-
83
- elif res['status'] == 'rejected':
84
- l.error('qr code rejected')
85
- generate_qr()
86
- stream.close()
87
- break
88
-
89
- elif res['status'] == 'scanned':
90
- iprint(l, 'qr code scanned')
91
- if status:
92
- status.update('Waiting for accept')
93
-
94
- elif res['status'] == 'pending':
95
- iprint(l, 'qr code pending')
96
-
97
- # elif res['status'] == 'captcha_required':
98
- # iprint(l, 'captcha required')
99
- # if status:
100
- # status.update('Solving captcha')
101
- # turnstile = get_turnstile(client)
102
-
103
- l.info('stop stream')
104
- sleep(5)
105
-
106
- l.error('all retries to auth qr exceeded')
107
- return False
108
- finally:
109
- if status:
110
- status.stop()
111
- if stream:
112
- stream.close()
@@ -1,4 +0,0 @@
1
- """Модели предметной области: пользователи, посты, комментарии и остальное
2
-
3
- Могут импортировать itd.core и itd.api
4
- """
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
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