itd-sdk 2.6.0__tar.gz → 2.7.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 (45) hide show
  1. {itd_sdk-2.6.0 → itd_sdk-2.7.0}/PKG-INFO +3 -2
  2. {itd_sdk-2.6.0 → itd_sdk-2.7.0}/README.md +1 -1
  3. itd_sdk-2.7.0/itd/_credfile.py +43 -0
  4. {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/api/comments.py +1 -1
  5. {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/api/notifications.py +0 -3
  6. {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/base.py +54 -15
  7. {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/client.py +161 -91
  8. {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/comment.py +73 -40
  9. {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/enums.py +7 -3
  10. {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/exceptions.py +1 -1
  11. {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/hashtag.py +4 -5
  12. {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/notification.py +26 -17
  13. {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/poll.py +7 -20
  14. {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/post.py +84 -76
  15. {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/request.py +2 -2
  16. {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/session.py +13 -4
  17. {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/user.py +30 -48
  18. {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/utils.py +24 -1
  19. {itd_sdk-2.6.0 → itd_sdk-2.7.0}/pyproject.toml +2 -1
  20. {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/__init__.py +0 -0
  21. {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/_default.py +0 -0
  22. {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/_limiter.py +0 -0
  23. {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/api/__init__.py +0 -0
  24. {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/api/auth.py +0 -0
  25. {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/api/dwell.py +0 -0
  26. {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/api/etc.py +0 -0
  27. {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/api/files.py +0 -0
  28. {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/api/hashtags.py +0 -0
  29. {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/api/pins.py +0 -0
  30. {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/api/platform.py +0 -0
  31. {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/api/polls.py +0 -0
  32. {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/api/posts.py +0 -0
  33. {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/api/reports.py +0 -0
  34. {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/api/search.py +0 -0
  35. {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/api/sessions.py +0 -0
  36. {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/api/subscription.py +0 -0
  37. {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/api/users.py +0 -0
  38. {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/api/verification.py +0 -0
  39. {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/clan.py +0 -0
  40. {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/file.py +0 -0
  41. {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/logger.py +0 -0
  42. {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/pin.py +0 -0
  43. {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/report.py +0 -0
  44. {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/span.py +0 -0
  45. {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: itd-sdk
3
- Version: 2.6.0
3
+ Version: 2.7.0
4
4
  Summary: ITD client for python
5
5
  Keywords: itd,itdsocial
6
6
  Author: fi-res
@@ -16,6 +16,7 @@ Requires-Dist: pydantic>=2.12.5,<2.14.0
16
16
  Requires-Dist: sseclient-py~=1.9.0
17
17
  Requires-Dist: telegramify-markdown>=0.5.4
18
18
  Requires-Dist: lxml>=6.1.1
19
+ Requires-Dist: platformdirs>=4.10.0
19
20
  Requires-Dist: dateparser>=1.2.2 ; extra == 'date'
20
21
  Requires-Dist: filetype>=1.2.0 ; extra == 'filetype'
21
22
  Requires-Dist: rich>=15.0.0 ; extra == 'logs'
@@ -29,7 +30,7 @@ Description-Content-Type: text/markdown
29
30
 
30
31
  # itd-sdk
31
32
 
32
- [![ncruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/fi-res/ncruff)
33
+ [![ncruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/fi-res/ncruff) [![EffVer Versioning](https://img.shields.io/badge/version_scheme-EffVer-0097a7)](https://jacobtomlinson.dev/effver)
33
34
 
34
35
  SDK для работы с [https://xn--d1ah4a.com](итд.com) на python.
35
36
 
@@ -1,6 +1,6 @@
1
1
  # itd-sdk
2
2
 
3
- [![ncruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/fi-res/ncruff)
3
+ [![ncruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/fi-res/ncruff) [![EffVer Versioning](https://img.shields.io/badge/version_scheme-EffVer-0097a7)](https://jacobtomlinson.dev/effver)
4
4
 
5
5
  SDK для работы с [https://xn--d1ah4a.com](итд.com) на python.
6
6
 
@@ -0,0 +1,43 @@
1
+ from datetime import datetime
2
+ from json import dumps, loads
3
+ from pathlib import Path
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+ # class CredfileSession(BaseModel):
8
+ # id: int
9
+ # last_active: datetime
10
+ # main: bool
11
+
12
+ # @classmethod
13
+ # def current(cls, main: bool = False):
14
+ # return cls(id=getpid(), last_active=datetime.now(), main=main)
15
+
16
+
17
+ class Credfile(BaseModel):
18
+ access: str | None = None
19
+ refresh: str | None = None
20
+ valid: bool = True
21
+ updated_at: datetime = Field(default_factory=datetime.now)
22
+ # sessions: list[CredfileSession] = Field(default_factory=lambda: [])
23
+ _file: Path
24
+
25
+ def flush(self):
26
+ self.updated_at = datetime.now()
27
+ self._file.write_text(dumps(self.model_dump(mode='json')))
28
+
29
+ def update(self):
30
+ new = self.model_validate(loads(self._file.read_text()))
31
+ if self.updated_at <= new.updated_at:
32
+ for field in Credfile.model_fields:
33
+ setattr(self, field, getattr(new, field))
34
+ return True
35
+ return False
36
+
37
+ # @field_validator('sessions', mode='after')
38
+ # def validate_sessions(self, sessions: list[CredfileSession]):
39
+ # for session in sessions.copy():
40
+ # if session.last_active < datetime.now() - timedelta(minutes=30):
41
+ # sessions.remove(session)
42
+
43
+ # sessions.append(CredfileSession.current(main=not any(session.main for session in sessions)))
@@ -50,7 +50,7 @@ def unlike_comment(client: Client, id: UUID):
50
50
  return client.request('delete', f'comments/{id}/like')
51
51
 
52
52
 
53
- @api_wrapper(NotFoundError('Comment'), AlreadyDeletedError('Comment'))
53
+ @api_wrapper(NotFoundError('Comment'), AlreadyDeletedError('Comment'), ForbiddenError('delete comment'))
54
54
  def delete_comment(client: Client, id: UUID):
55
55
  return client.request('delete', f'comments/{id}')
56
56
 
@@ -44,10 +44,7 @@ def update_notifications_settings(client: Client, settings: NotificationsSetting
44
44
 
45
45
  data = {}
46
46
  if old:
47
- print(settings.follows)
48
-
49
47
  data.update(_NotificationsSettingsOld.model_validate(settings, from_attributes=True).model_dump(mode='json', by_alias=True))
50
- print(settings.follows)
51
48
 
52
49
  if new:
53
50
  data.update(
@@ -1,12 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
- import inspect
4
3
  from abc import abstractmethod
5
4
  from functools import wraps
6
5
  from time import sleep
7
6
  from typing import TYPE_CHECKING, Any, Callable, Iterator, SupportsIndex, TypeVar, cast, overload
7
+ from uuid import UUID
8
8
 
9
9
  from pydantic import BaseModel
10
+ from pydantic.fields import FieldInfo
10
11
  from requests import Response
11
12
  from requests.exceptions import JSONDecodeError
12
13
 
@@ -52,6 +53,7 @@ class ITDBaseModel:
52
53
  self._client = client or get_default_client()
53
54
 
54
55
  self._loaded_attrs: set[str] = set()
56
+ self._extra_context = {}
55
57
 
56
58
  def __setattr__(self, name: str, value: Any) -> None:
57
59
  if isinstance(value, ITDBaseModel) and (client := _getattr(self, '_client')): # ai
@@ -73,13 +75,14 @@ class ITDBaseModel:
73
75
  if not self._refreshable:
74
76
  l.warning(f'{self.__class__.__name__} is not refreshable but refresh is called')
75
77
  self.load_status = LoadStatus.LOADING
76
- self._fill_from_data(self._refresh(client=client or self.client))
78
+ self._fill_from_data(self._refresh(client=client or self.client), context={'client': client or self.client})
77
79
  self.load_status = LoadStatus.FULL
78
80
  return self
79
81
 
80
- def _fill_from_data(self, data: dict):
82
+ def _fill_from_data(self, data: dict, *, context: dict = {}):
81
83
  assert self._validator, 'Unable to use fill_from_data without a validator'
82
- validated = self._validator().model_validate(data) # ty: ignore[missing-argument]
84
+ context.update(self._extra_context)
85
+ validated = self._validator().model_validate(data, context=context) # ty: ignore[missing-argument]
83
86
  self._loaded_attrs = validated.model_fields_set # значения автоматом добавляются через setattr # так значит это же тогда надо закоментить? # хз наверн
84
87
  for name, value in validated.__dict__.items():
85
88
  object.__setattr__(self, name, value)
@@ -87,11 +90,12 @@ class ITDBaseModel:
87
90
  self._post_refresh()
88
91
 
89
92
  @classmethod
90
- def from_dict(cls, data: dict, *, client: Client | None = None):
93
+ def from_dict(cls, data: dict, *, context: dict = {}, client: Client | None = None):
91
94
  instance = cls.__new__(cls)
92
95
  ITDBaseModel.__init__(instance, client)
93
96
 
94
- instance._fill_from_data(data)
97
+ context.setdefault('client', client or instance.client)
98
+ instance._fill_from_data(data, context=context)
95
99
  instance.load_status = LoadStatus.PARTIALLY
96
100
  return instance
97
101
 
@@ -105,22 +109,44 @@ class ITDBaseModel:
105
109
  value = None
106
110
  exc = e
107
111
 
108
- if name == 'is_following':
109
- l.debug(_getattr(self, '_loaded_attrs'))
110
112
  if (
111
113
  _getattr(self, '_refreshable')
112
114
  and not name.startswith('_')
113
115
  and name not in ('client', 'model_fields_set', 'load_status')
114
116
  and not callable(value)
115
117
  and not isinstance(_getattr(type(self), name), property)
116
- and name not in _getattr(self, '_loaded_attrs')
117
118
  and _getattr(self, 'load_status') in (LoadStatus.NO, LoadStatus.PARTIALLY)
119
+ and name not in (_getattr(self, '_loaded_attrs') or {name})
120
+ and self.client.config.load_on_getattr
118
121
  and not (isinstance(value, ITDBaseModel) and not value._load_with_parent)
119
122
  ):
120
123
  l.info('refresh %s field=%s load_status=%s', self.__class__.__name__, name, _getattr(self, 'load_status').value)
121
124
  self.refresh()
122
125
  return object.__getattribute__(self, name)
123
126
 
127
+ annotations = {}
128
+ [annotations.update(_getattr(c, '__annotations__')) for c in _getattr(self, '__class__').__mro__ if _getattr(c, '__annotations__')]
129
+
130
+ if isinstance(value, FieldInfo) or (not name.startswith('_') and exc is not None and not _getattr(self, 'client').config.load_on_getattr):
131
+ value_type = annotations[name]
132
+ if 'None' in value_type:
133
+ l.warning('returned fake value None for %s: please load data (call refresh()) before get attributes', name)
134
+ return None
135
+ elif value_type == 'int':
136
+ l.warning('returned fake value 0 for %s: please load data (call refresh()) before get attributes', name)
137
+ return 0
138
+ elif value_type == 'UUID':
139
+ l.warning('returned fake value 00000000-0000-0000-0000-000000000010 for %s: please load data (call refresh()) before get attributes', name)
140
+ return UUID('00000000-0000-0000-0000-000000000000')
141
+ elif value_type == 'str':
142
+ l.warning('returned fake value "" for %s: please load data (call refresh()) before get attributes', name)
143
+ return ''
144
+ elif value_type == 'bool':
145
+ l.warning('returned fake value False for %s: please load data (call refresh()) before get attributes', name)
146
+ return False # дилема
147
+ l.error('cannot fake data for %s (type %s): please load data (call refresh()) before get attributes', name, value_type)
148
+ raise RuntimeError(f'cannot fake data for {name} (type {value_type}): please load data (call refresh()) before get attributes')
149
+
124
150
  if exc is not None:
125
151
  raise exc
126
152
  return value
@@ -136,6 +162,7 @@ class ITDList(ITDBaseModel, list[T]):
136
162
  _refreshable = False
137
163
  has_more = True
138
164
  idx = 0
165
+ _is_page_pagination: bool = False
139
166
 
140
167
  def _fetch(self, client: Client, limit: int) -> dict:
141
168
  return {}
@@ -157,9 +184,15 @@ class ITDList(ITDBaseModel, list[T]):
157
184
  l.warning('skip load because has_more=False')
158
185
  return []
159
186
 
160
- limit = limit or (client or self.client).config.batch_sizes._values[self.__class__.__name__.lower()]
187
+ default_limit = (client or self.client).config.batch_sizes._values[self.__class__.__name__.lower()]
188
+ limit = limit or default_limit
161
189
  if isinstance(count, int) and count < limit:
162
190
  limit = count
191
+
192
+ if self._is_page_pagination and limit != default_limit:
193
+ l.warning('unable to change limit in %s (page pagination), using default %s', self.__class__.__name__.lower(), default_limit)
194
+ count = limit = default_limit
195
+
163
196
  l.debug('load %s count=%s limit=%s cursor=%s', self.__class__.__name__.lower(), count, limit, self.cursor)
164
197
 
165
198
  # Batch = load one batch (limit), All = load everything, int = load exactly N
@@ -265,7 +298,7 @@ class ITDList(ITDBaseModel, list[T]):
265
298
  if value is None or isinstance(self.client.config.load_on_getitem, All):
266
299
  l.debug('getitem load all')
267
300
  self.load_all()
268
- elif isinstance(self.client.config.load_on_getitem, Batch):
301
+ elif self._is_page_pagination or isinstance(self.client.config.load_on_getitem, Batch):
269
302
  l.debug('getitem load batch')
270
303
  self.load(BATCH)
271
304
  else:
@@ -279,7 +312,7 @@ class ITDList(ITDBaseModel, list[T]):
279
312
  if getattr(self, 'total', None) and self.idx >= self.total:
280
313
  raise StopIteration()
281
314
  if self.idx >= len(self) and (self.has_more or self.client.config.force_load_lists):
282
- l.debug('not enough items to call next - load')
315
+ l.debug('not enough items to call next, load')
283
316
  self.load(self.client.config.load_on_iter)
284
317
  if self.idx >= len(self):
285
318
  raise StopIteration()
@@ -297,6 +330,11 @@ class ITDList(ITDBaseModel, list[T]):
297
330
  def all(self) -> list[T]:
298
331
  return self.load_all()
299
332
 
333
+ def clear(self):
334
+ super().clear()
335
+ self.cursor = None
336
+ self.has_more = True
337
+
300
338
 
301
339
  def _filter_bytes(args: tuple):
302
340
  filtered = []
@@ -385,9 +423,10 @@ def api_wrapper(*exceptions: ITDException):
385
423
  if isinstance(exception, RateLimitError) and isinstance(json.get('error'), dict):
386
424
  exception.retry_after = json.get('error', {}).get('retryAfter', 0)
387
425
 
388
- if isinstance(exception, (UnauthorizedError, AccessTokenExpiredError)) and client.refresh_token:
389
- client.refresh_auth()
390
- return wrapper(client, *args, **kwargs)
426
+ # now checks token before request
427
+ # if isinstance(exception, (UnauthorizedError, AccessTokenExpiredError)) and client.refresh_token:
428
+ # client.refresh_auth()
429
+ # return wrapper(client, *args, **kwargs)
391
430
 
392
431
  if isinstance(exception, AccountDeletedError):
393
432
  exception.can_restore = json.get('error', {}).get('canRestore', True)
@@ -1,7 +1,10 @@
1
1
  from _io import BufferedReader
2
2
  from atexit import register
3
3
  from dataclasses import dataclass, field
4
- from datetime import datetime
4
+ from datetime import datetime, timedelta
5
+ from functools import cached_property
6
+ from json import dumps, loads
7
+ from pathlib import Path
5
8
  from threading import Thread
6
9
  from time import sleep
7
10
  from typing import Callable, Literal, overload
@@ -12,19 +15,19 @@ from requests import Session
12
15
  from requests.adapters import HTTPAdapter
13
16
  from requests.utils import default_user_agent
14
17
 
15
- from itd import BurstRateLimiter, HalfRateLimiter, IPRateLimiter, LimiterConfig, set_limiter_config
18
+ from itd._credfile import Credfile
16
19
  from itd._default import _default_client, set_default_client
17
20
  from itd.api.auth import change_password, logout, refresh_token
18
21
  from itd.api.posts import get_stats
19
22
  from itd.api.search import search
20
- from itd.enums import BATCH, All, AuthLevel, Batch, DebugResponseMode, ParseMode, RateLimitMode, Role, UserAgent
21
- from itd.exceptions import InsufficientAuthLevelError, NotFoundError, RateLimitError
23
+ from itd.enums import BATCH, All, AuthLevel, Batch, DebugResponseMode, ParseMode, Role, UserAgent, ViewReason
24
+ from itd.exceptions import InsufficientAuthLevelError, NotFoundError, RateLimitError, SessionExpiredError, SessionNotFoundError, SessionRevokedError
22
25
  from itd.hashtag import Hashtag, Hashtags
23
26
  from itd.logger import get_logger
24
27
  from itd.post import DwellTracker, Post
25
28
  from itd.request import decode_jwt_payload, fetch, fetch_stream
26
29
  from itd.user import Me, User, Users, get_follow_status
27
- from itd.utils import get_sdk_user_agent
30
+ from itd.utils import get_credfile, get_sdk_user_agent, shorten_token
28
31
 
29
32
  l = get_logger('client') # noqa: E741
30
33
 
@@ -75,13 +78,6 @@ class Config:
75
78
  # ^ это если что просто мысли, не обращайте внимания
76
79
  client_type: Literal['client', 'bot', 'onetime'] = 'onetime'
77
80
 
78
- rate_limit: RateLimitMode | None = None # deprecated
79
- rate_limit_default: int | None = None # deprecated
80
- rate_limit_actions: dict[str, float | int] | None = None # deprecated
81
- anti_rate_limit: bool | None = None # deprecated
82
- burst_requests: bool | None = None # deprecated
83
- anti_ip_ban: bool | None = None # deprecated
84
- limit_coefficient: float | None = None # deprecated
85
81
  auto_acquire: bool | None = None
86
82
 
87
83
  # enable_logging: bool | None = None
@@ -91,8 +87,8 @@ class Config:
91
87
 
92
88
  userposts_add_pinned_post: bool = True
93
89
 
94
- # load_on_init: bool = True
95
- # load_on_getattr: bool = False
90
+ # load_on_init: bool = False
91
+ load_on_getattr: bool = True
96
92
  auto_load: bool | None = None
97
93
  load_on_getitem: int | All | Batch | None = 1
98
94
  load_on_iter: int | All | Batch | None = BATCH
@@ -105,7 +101,6 @@ class Config:
105
101
  timeout_file_download: float | None = None
106
102
 
107
103
  url: str = 'https://xn--d1ah4a.com/api'
108
- url_api: str | None = None # deprecated
109
104
  user_agent: UserAgent | str = UserAgent.BROWSER
110
105
  solve_challenge: bool = True
111
106
 
@@ -116,7 +111,7 @@ class Config:
116
111
  retry_enabled: bool | None = None
117
112
  retry_delay: float = 10 # delay before next attempt (after rate limit error) if retry_after is not provided in response
118
113
  retry_max_retries: int | None = 10 # none for no limit
119
- retry_exceptions: tuple[type[Exception]] = field(default_factory=lambda: (RateLimitError,))
114
+ retry_exceptions: tuple[type[Exception], ...] = field(default_factory=lambda: (RateLimitError,))
120
115
  retry_max_retry_after: int = 500
121
116
 
122
117
  bypass_auth_level: bool = False
@@ -126,6 +121,11 @@ class Config:
126
121
  dwell_send_interval: float = 2
127
122
  dwell_save_on_quit: bool = True
128
123
  dwell_wait_durations: bool = False
124
+ dwell_max_duration: int = 30000
125
+ dwell_min_duration: int = 250
126
+ dwell_check_active: bool | None = None
127
+ dwell_check_active_interval: float = 5
128
+ dwell_inactive_timeout: int = 30
129
129
  post_view_increment: bool = False
130
130
  post_auto_view: bool = True # view when called post.set_invisible()
131
131
 
@@ -140,10 +140,6 @@ class Config:
140
140
  refresh_token_cookie_name: str = 'refresh_token'
141
141
 
142
142
  def __post_init__(self):
143
- if self.url_api is not None:
144
- l.warning('config.url_api is deprecated and will be removed in 2.7.0. Please use config.url.')
145
- self.url = self.url_api
146
-
147
143
  match self.user_agent:
148
144
  case UserAgent.DEFAULT:
149
145
  self._user_agent = default_user_agent()
@@ -156,39 +152,6 @@ class Config:
156
152
  case _:
157
153
  self._user_agent = self.user_agent
158
154
 
159
- if self.dwell_wait_durations:
160
- l.warning('config.dwell_wait_durations is deprecated and will be removed in 2.6.0.')
161
-
162
- if self.rate_limit is not None:
163
- l.warning('config.rate_limit is deprecated and will be removed in 2.7.0.')
164
- if self.rate_limit_default is not None:
165
- l.warning('config.rate_limit_default is deprecated and will be removed in 2.7.0.')
166
- if self.rate_limit_actions is not None:
167
- l.warning('config.rate_limit_actions is deprecated and will be removed in 2.7.0.')
168
- if self.anti_ip_ban is not None:
169
- l.warning(
170
- f'config.anti_ip_ban is deprecated and will be removed in 2.7.0. '
171
- f'Please use set_limiter_config(LimiterConfig(ip_limiter={"IPRateLimiter" if self.anti_ip_ban else None})).'
172
- )
173
- set_limiter_config(LimiterConfig(ip_limiter=IPRateLimiter() if self.anti_ip_ban else None))
174
- if self.anti_rate_limit is not None:
175
- l.warning(
176
- f'config.anti_rate_limit is depreacred and will be removed in 2.7.0. '
177
- f'Please use set_limiter_config(limiter={"HalfRateLimiter" if self.anti_rate_limit else None}).'
178
- )
179
- set_limiter_config(LimiterConfig(limiter=HalfRateLimiter if self.anti_rate_limit else None))
180
- if self.burst_requests is not None:
181
- l.warning(
182
- f'config.anti_rate_limit is depreacred and will be removed in 2.7.0. '
183
- f'Please use set_limiter_config(limiter={"BurstRateLimiter" if self.burst_requests else None}).'
184
- )
185
- set_limiter_config(LimiterConfig(limiter=BurstRateLimiter if self.burst_requests else None))
186
- if self.limit_coefficient is not None:
187
- l.warning(
188
- 'config.limit_coefficient is deprecated and will be removed in 2.7.0. '
189
- 'Please define your own rate limiter that will override sync function of RateLimiter class and apply coefficient to remaining.'
190
- )
191
-
192
155
  if self.timeout is None:
193
156
  match self.client_type:
194
157
  case 'onetime':
@@ -210,8 +173,17 @@ class Config:
210
173
  else:
211
174
  self._post_update_stats = self.post_update_stats
212
175
 
176
+ if self.dwell_check_active is None:
177
+ self._dwell_check_active = self.client_type == 'client'
178
+ else:
179
+ self._dwell_check_active = self.dwell_check_active
180
+
213
181
  if self.load_comments_from_post is not None:
214
- l.warning('load_comments_from_post is deprecated and will be removed in 2.7.0.')
182
+ l.warning('config.load_comments_from_post is deprecated and will be removed in 2.7.0.')
183
+
184
+ if self.auto_load is not None:
185
+ l.warning('config.auto_load is deprecated and will be removed in 2.8.0. Please use load_on_getattr.')
186
+ self.load_on_getattr = self.auto_load
215
187
 
216
188
 
217
189
  class AccessToken(BaseModel):
@@ -239,8 +211,10 @@ class Client:
239
211
  self.access_token: str | None = None
240
212
  self.access_token_data: AccessToken | None = None
241
213
  self.refresh_token: str | None = None
242
- self._user: Me | None = None
243
214
  self.visible_posts: list[Post] = []
215
+ self._visible_posts_buffer: list[Post] = []
216
+ self.last_active = datetime.now()
217
+ self._credfile: Credfile | None = None
244
218
 
245
219
  self.session = Session()
246
220
  adapter = HTTPAdapter(pool_connections=1, pool_maxsize=10, pool_block=False) # idk what is this, (claude added) just for better stability
@@ -248,7 +222,7 @@ class Client:
248
222
 
249
223
  if access:
250
224
  self.auth_level = AuthLevel.ACCESS
251
- self.access_token = access.replace('Bearer ', '')
225
+ self.token = access.replace('Bearer ', '')
252
226
 
253
227
  if refresh:
254
228
  self.session.cookies.set(config.refresh_token_cookie_name, refresh, path='/')
@@ -260,14 +234,117 @@ class Client:
260
234
  if _default_client is None or config.is_default:
261
235
  set_default_client(self)
262
236
 
263
- if self.auth_level > AuthLevel.NO and self.config.dwell_enabled:
264
- self.dwell_tracker = DwellTracker(self)
265
- self.dwell_tracker._start_timer()
266
- else:
267
- self.dwell_tracker = None
237
+ self.dwell_tracker = DwellTracker(self)
238
+ self.dwell_tracker._start_timer()
268
239
 
269
- if self.config.post_update_stats:
240
+ if self.config._post_update_stats:
270
241
  self._start_update_timer()
242
+ if self.config._dwell_check_active:
243
+ self._start_check_active_timer()
244
+
245
+ @classmethod
246
+ def from_file(cls, name: str, config: Config = Config()):
247
+ credfile = get_credfile(name)
248
+ l.debug('get credentials file refresh=%s access=%s', shorten_token(credfile.refresh), shorten_token(credfile.access))
249
+ if not credfile.valid:
250
+ l.warning('last refresh token was expired or not found. Please enter a new one:')
251
+ update = True
252
+ else:
253
+ update = credfile.refresh is None
254
+
255
+ if update:
256
+ try:
257
+ from rich.prompt import Prompt
258
+ except ImportError:
259
+ credfile.refresh = input('refresh token: ')
260
+ else:
261
+ credfile.refresh = Prompt.ask('[cyan]refresh token[/]')
262
+
263
+ instance = cls(credfile.refresh, credfile.access, config=config)
264
+ if instance.access_token_data and instance.access_token_data.expired_at < datetime.now():
265
+ instance.access_token = instance.access_token_data = None
266
+ instance._credfile = credfile
267
+ if update:
268
+ instance._update_file()
269
+
270
+ def _update_file(self, valid: bool = True):
271
+ if self._credfile is None:
272
+ return
273
+
274
+ l.debug('update credentials file refresh=%s access=%s', shorten_token(self.refresh_token), shorten_token(self.access_token))
275
+ if not valid:
276
+ l.warning('mark %s as not valid', shorten_token(self.refresh_token))
277
+
278
+ self._credfile.access = self.access_token
279
+ self._credfile.refresh = self.refresh_token
280
+ self._credfile.valid = valid
281
+ self._credfile.flush()
282
+
283
+ def refresh_auth(self, force: bool = False) -> str:
284
+ """Обновить access token
285
+
286
+ Returns:
287
+ str: Токен
288
+ """
289
+
290
+ l.debug('refresh access_token')
291
+ if not force and self._credfile and self._credfile.update():
292
+ self.token = self._credfile.access
293
+ self.refresh_token = self._credfile.refresh
294
+
295
+ if self.access_token:
296
+ assert self.access_token_data
297
+ if self.access_token_data.expired_at > datetime.now():
298
+ l.debug('update access_token from credfile')
299
+ return self.access_token
300
+ else:
301
+ l.info('credfile access_token expired')
302
+ else:
303
+ l.info('crefile access_token is none')
304
+
305
+ try:
306
+ res = refresh_token(self)
307
+ except (SessionExpiredError, SessionNotFoundError, SessionRevokedError):
308
+ self._update_file(valid=False)
309
+ raise
310
+
311
+ self.token = res.json().get('accessToken') or res.json()['token']
312
+ if 'refresh_token' in res.cookies:
313
+ self.refresh_token = res.cookies['refresh_token']
314
+ self._update_file()
315
+
316
+ return self.token
317
+
318
+ def _start_check_active_timer(self):
319
+ l.debug('start check active timer')
320
+ if not self.config.dwell_check_active_interval:
321
+ return
322
+
323
+ def loop():
324
+ while True:
325
+ sleep(self.config.dwell_check_active_interval)
326
+ if self.last_active + timedelta(seconds=self.config.dwell_inactive_timeout) < datetime.now():
327
+ self._visible_posts_buffer = self.visible_posts.copy()
328
+ for post in self._visible_posts_buffer:
329
+ post.set_invisible(reason=ViewReason.INACTIVE)
330
+ elif self._visible_posts_buffer:
331
+ for post in self._visible_posts_buffer:
332
+ post.set_visible()
333
+ self._visible_posts_buffer.clear()
334
+
335
+ self._check_active_thread = Thread(target=loop)
336
+ self._check_active_thread.daemon = True
337
+ self._check_active_thread.start()
338
+
339
+ def on_exit():
340
+ l.debug('stop check active timer')
341
+ if self._check_active_thread:
342
+ self._check_active_thread.join(timeout=0)
343
+
344
+ register(on_exit)
345
+
346
+ def set_active(self): # call when user is active (scroll, move etc)
347
+ self.last_active = datetime.now()
271
348
 
272
349
  def _start_update_timer(self):
273
350
  l.debug('start update timer')
@@ -279,14 +356,14 @@ class Client:
279
356
  sleep(self.config.post_update_stats_interval)
280
357
  self.update_post_stats()
281
358
 
282
- self._thread = Thread(target=loop)
283
- self._thread.daemon = True
284
- self._thread.start()
359
+ self._update_thread = Thread(target=loop)
360
+ self._update_thread.daemon = True
361
+ self._update_thread.start()
285
362
 
286
363
  def on_exit():
287
364
  l.debug('stop update timer')
288
- if self._thread:
289
- self._thread.join(timeout=0)
365
+ if self._update_thread:
366
+ self._update_thread.join(timeout=0)
290
367
 
291
368
  register(on_exit)
292
369
 
@@ -304,7 +381,11 @@ class Client:
304
381
  if level > self.auth_level and not self.config.bypass_auth_level:
305
382
  raise InsufficientAuthLevelError(self.auth_level, level)
306
383
 
307
- if level >= AuthLevel.ACCESS and self.access_token is None and url != 'v1/auth/refresh':
384
+ if (
385
+ level >= AuthLevel.ACCESS
386
+ and (self.access_token is None or (self.access_token_data and self.access_token_data.expired_at <= datetime.now()))
387
+ and url != 'v1/auth/refresh'
388
+ ):
308
389
  self.refresh_auth()
309
390
 
310
391
  return fetch(self, method, url, params, files)
@@ -334,39 +415,28 @@ class Client:
334
415
  assert self.access_token, 'Access token not refreshed yet'
335
416
  return self.access_token
336
417
 
418
+ @token.setter
419
+ def token(self, token: str | None):
420
+ self.access_token = token
421
+ if token is None:
422
+ self.access_token_data = None
423
+ else:
424
+ self.access_token_data = AccessToken.model_validate(decode_jwt_payload(token))
425
+
337
426
  @property
338
427
  def user_id(self) -> UUID:
339
428
  assert self.access_token_data
340
429
  return self.access_token_data.subject_id
341
430
 
342
- @property
431
+ @cached_property
343
432
  def user(self) -> Me:
344
- if not self._user:
345
- self._user = Me(self)
346
- return self._user
433
+ return Me(self)
347
434
 
348
435
  def _process_exc_callbacks(self, exception: Exception):
349
436
  # l.debug([v for k, v in self.config.on_exceptions.items() if k in exception.__class__.mro()])
350
437
  for callback in [v for k, v in self.config.on_exceptions.items() if k in exception.__class__.mro()]:
351
438
  callback(exception)
352
439
 
353
- def refresh_auth(self) -> str:
354
- """Обновить access token
355
-
356
- Returns:
357
- str: Токен
358
- """
359
- l.debug('refresh token')
360
-
361
- res = refresh_token(self)
362
- res.raise_for_status()
363
-
364
- self.access_token = res.json().get('accessToken') or res.json()['token']
365
- self.access_token_data = AccessToken.model_validate(decode_jwt_payload(self.access_token))
366
-
367
- assert self.access_token
368
- return self.access_token
369
-
370
440
  def logout(self):
371
441
  """Выход из аккаунта"""
372
442
  res = logout(self)
@@ -479,5 +549,5 @@ class Client:
479
549
  return get_follow_status(users)
480
550
 
481
551
 
482
- def init_client(refresh: str | None = None, access: str | None = None, config: Config = Config()):
483
- return Client(refresh, access, config)
552
+ def init_client(name: str | None = None, config: Config = Config()):
553
+ return Client.from_file(name or 'default', config=config)