pararamio-aio 2.1.1__py3-none-any.whl
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.
- pararamio_aio/__init__.py +78 -0
- pararamio_aio/_core/__init__.py +125 -0
- pararamio_aio/_core/_types.py +120 -0
- pararamio_aio/_core/base.py +143 -0
- pararamio_aio/_core/client_protocol.py +90 -0
- pararamio_aio/_core/constants/__init__.py +7 -0
- pararamio_aio/_core/constants/base.py +9 -0
- pararamio_aio/_core/constants/endpoints.py +84 -0
- pararamio_aio/_core/cookie_decorator.py +208 -0
- pararamio_aio/_core/cookie_manager.py +1222 -0
- pararamio_aio/_core/endpoints.py +67 -0
- pararamio_aio/_core/exceptions/__init__.py +6 -0
- pararamio_aio/_core/exceptions/auth.py +91 -0
- pararamio_aio/_core/exceptions/base.py +124 -0
- pararamio_aio/_core/models/__init__.py +17 -0
- pararamio_aio/_core/models/base.py +66 -0
- pararamio_aio/_core/models/chat.py +92 -0
- pararamio_aio/_core/models/post.py +65 -0
- pararamio_aio/_core/models/user.py +54 -0
- pararamio_aio/_core/py.typed +2 -0
- pararamio_aio/_core/utils/__init__.py +73 -0
- pararamio_aio/_core/utils/async_requests.py +417 -0
- pararamio_aio/_core/utils/auth_flow.py +202 -0
- pararamio_aio/_core/utils/authentication.py +235 -0
- pararamio_aio/_core/utils/captcha.py +92 -0
- pararamio_aio/_core/utils/helpers.py +336 -0
- pararamio_aio/_core/utils/http_client.py +199 -0
- pararamio_aio/_core/utils/requests.py +424 -0
- pararamio_aio/_core/validators.py +78 -0
- pararamio_aio/_types.py +29 -0
- pararamio_aio/client.py +989 -0
- pararamio_aio/constants/__init__.py +16 -0
- pararamio_aio/cookie_manager.py +15 -0
- pararamio_aio/exceptions/__init__.py +31 -0
- pararamio_aio/exceptions/base.py +1 -0
- pararamio_aio/file_operations.py +232 -0
- pararamio_aio/models/__init__.py +32 -0
- pararamio_aio/models/activity.py +127 -0
- pararamio_aio/models/attachment.py +141 -0
- pararamio_aio/models/base.py +83 -0
- pararamio_aio/models/bot.py +274 -0
- pararamio_aio/models/chat.py +722 -0
- pararamio_aio/models/deferred_post.py +174 -0
- pararamio_aio/models/file.py +103 -0
- pararamio_aio/models/group.py +361 -0
- pararamio_aio/models/poll.py +275 -0
- pararamio_aio/models/post.py +643 -0
- pararamio_aio/models/team.py +403 -0
- pararamio_aio/models/user.py +239 -0
- pararamio_aio/py.typed +2 -0
- pararamio_aio/utils/__init__.py +18 -0
- pararamio_aio/utils/authentication.py +383 -0
- pararamio_aio/utils/requests.py +75 -0
- pararamio_aio-2.1.1.dist-info/METADATA +269 -0
- pararamio_aio-2.1.1.dist-info/RECORD +57 -0
- pararamio_aio-2.1.1.dist-info/WHEEL +5 -0
- pararamio_aio-2.1.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1222 @@
|
|
1
|
+
"""Cookie management with versioning, locking and multiple storage backends."""
|
2
|
+
# pylint: disable=too-many-lines
|
3
|
+
|
4
|
+
from __future__ import annotations
|
5
|
+
|
6
|
+
import asyncio
|
7
|
+
import functools
|
8
|
+
import json
|
9
|
+
import logging
|
10
|
+
import os
|
11
|
+
import threading
|
12
|
+
import time
|
13
|
+
import uuid
|
14
|
+
from abc import ABC, abstractmethod
|
15
|
+
from datetime import datetime, timezone
|
16
|
+
from http.cookiejar import Cookie, CookieJar
|
17
|
+
from pathlib import Path
|
18
|
+
from typing import Any, Callable, TypeVar
|
19
|
+
|
20
|
+
from .exceptions.base import PararamioException
|
21
|
+
|
22
|
+
T = TypeVar('T')
|
23
|
+
|
24
|
+
|
25
|
+
log = logging.getLogger(__name__)
|
26
|
+
|
27
|
+
|
28
|
+
class CookieManager(ABC):
|
29
|
+
"""Abstract cookie manager with versioning and locking support."""
|
30
|
+
|
31
|
+
@abstractmethod
|
32
|
+
def load_cookies(self) -> bool:
|
33
|
+
"""Load cookies from storage."""
|
34
|
+
|
35
|
+
@abstractmethod
|
36
|
+
def save_cookies(self) -> None:
|
37
|
+
"""Save cookies to storage."""
|
38
|
+
|
39
|
+
@abstractmethod
|
40
|
+
def add_cookie(self, cookie: Cookie) -> None:
|
41
|
+
"""Add or update a cookie."""
|
42
|
+
|
43
|
+
@abstractmethod
|
44
|
+
def get_cookie(self, domain: str, path: str, name: str) -> Cookie | None:
|
45
|
+
"""Get a specific cookie."""
|
46
|
+
|
47
|
+
@abstractmethod
|
48
|
+
def get_all_cookies(self) -> list[Cookie]:
|
49
|
+
"""Get all cookies."""
|
50
|
+
|
51
|
+
@abstractmethod
|
52
|
+
def clear_cookies(self) -> None:
|
53
|
+
"""Clear all cookies."""
|
54
|
+
|
55
|
+
@abstractmethod
|
56
|
+
def update_from_jar(self, cookie_jar: CookieJar) -> None:
|
57
|
+
"""Update cookies from a CookieJar."""
|
58
|
+
|
59
|
+
@abstractmethod
|
60
|
+
def acquire_auth_lock(self, timeout: float = 30.0) -> bool:
|
61
|
+
"""Acquire lock for authentication process."""
|
62
|
+
|
63
|
+
@abstractmethod
|
64
|
+
def release_auth_lock(self) -> None:
|
65
|
+
"""Release authentication lock."""
|
66
|
+
|
67
|
+
@abstractmethod
|
68
|
+
def check_version(self) -> bool:
|
69
|
+
"""Check if our version matches storage version."""
|
70
|
+
|
71
|
+
@abstractmethod
|
72
|
+
def refresh_if_needed(self) -> bool:
|
73
|
+
"""Reload cookies if version changed."""
|
74
|
+
|
75
|
+
@abstractmethod
|
76
|
+
def handle_auth_error(self, retry_callback: Callable[..., T], *args: Any, **kwargs: Any) -> T:
|
77
|
+
"""Handle authentication error with version check and retry."""
|
78
|
+
|
79
|
+
|
80
|
+
class CookieManagerMixin:
|
81
|
+
"""Mixin with common cookie management functionality."""
|
82
|
+
|
83
|
+
# These attributes are expected to be defined by classes using this mixin
|
84
|
+
_cookies: dict[str, Cookie]
|
85
|
+
_version: int
|
86
|
+
|
87
|
+
@staticmethod
|
88
|
+
def make_key(cookie: Cookie) -> str:
|
89
|
+
"""Make a unique key for cookie."""
|
90
|
+
return f'{cookie.domain}:{cookie.path}:{cookie.name}'
|
91
|
+
|
92
|
+
@staticmethod
|
93
|
+
def cookie_to_dict(cookie: Cookie) -> dict[str, Any]:
|
94
|
+
"""Convert Cookie object to dictionary."""
|
95
|
+
return {
|
96
|
+
'name': cookie.name,
|
97
|
+
'value': cookie.value,
|
98
|
+
'domain': cookie.domain,
|
99
|
+
'path': cookie.path,
|
100
|
+
'secure': cookie.secure,
|
101
|
+
'expires': cookie.expires,
|
102
|
+
'discard': cookie.discard,
|
103
|
+
'comment': cookie.comment,
|
104
|
+
'comment_url': cookie.comment_url,
|
105
|
+
'rfc2109': cookie.rfc2109,
|
106
|
+
'port': cookie.port,
|
107
|
+
'port_specified': cookie.port_specified,
|
108
|
+
'domain_specified': cookie.domain_specified,
|
109
|
+
'domain_initial_dot': cookie.domain_initial_dot,
|
110
|
+
'path_specified': cookie.path_specified,
|
111
|
+
'version': cookie.version,
|
112
|
+
}
|
113
|
+
|
114
|
+
@staticmethod
|
115
|
+
def dict_to_cookie(data: dict[str, Any]) -> Cookie | None:
|
116
|
+
"""Convert dictionary to Cookie object."""
|
117
|
+
try:
|
118
|
+
return Cookie(
|
119
|
+
version=data.get('version', 0),
|
120
|
+
name=data['name'],
|
121
|
+
value=data['value'],
|
122
|
+
port=data.get('port'),
|
123
|
+
port_specified=data.get('port_specified', False),
|
124
|
+
domain=data['domain'],
|
125
|
+
domain_specified=data.get('domain_specified', True),
|
126
|
+
domain_initial_dot=data.get('domain_initial_dot', False),
|
127
|
+
path=data['path'],
|
128
|
+
path_specified=data.get('path_specified', True),
|
129
|
+
secure=data.get('secure', False),
|
130
|
+
expires=data.get('expires'),
|
131
|
+
discard=data.get('discard', False),
|
132
|
+
comment=data.get('comment'),
|
133
|
+
comment_url=data.get('comment_url'),
|
134
|
+
rest={},
|
135
|
+
rfc2109=data.get('rfc2109', False),
|
136
|
+
)
|
137
|
+
except (KeyError, TypeError):
|
138
|
+
log.exception('Failed to create cookie from dict')
|
139
|
+
return None
|
140
|
+
|
141
|
+
def populate_jar(self, cookie_jar: CookieJar) -> None:
|
142
|
+
"""Populate a CookieJar with our cookies."""
|
143
|
+
cookies = getattr(self, '_cookies', {})
|
144
|
+
# Check if we need thread safety
|
145
|
+
lock = getattr(self, '_lock', None)
|
146
|
+
if lock is not None:
|
147
|
+
with lock:
|
148
|
+
for cookie in cookies.values():
|
149
|
+
cookie_jar.set_cookie(cookie)
|
150
|
+
else:
|
151
|
+
for cookie in cookies.values():
|
152
|
+
cookie_jar.set_cookie(cookie)
|
153
|
+
|
154
|
+
def has_cookies(self) -> bool:
|
155
|
+
"""Check if manager has any cookies."""
|
156
|
+
return bool(getattr(self, '_cookies', {}))
|
157
|
+
|
158
|
+
def _get_file_version(self) -> int:
|
159
|
+
"""Get current version from file."""
|
160
|
+
version_path = getattr(self, 'version_path', None)
|
161
|
+
if not version_path or not version_path.exists():
|
162
|
+
return 0
|
163
|
+
|
164
|
+
try:
|
165
|
+
with version_path.open(encoding='utf-8') as f:
|
166
|
+
return int(f.read().strip())
|
167
|
+
except (OSError, ValueError):
|
168
|
+
return 0
|
169
|
+
|
170
|
+
def _load_cookies_from_json(self, data: str | None) -> bool:
|
171
|
+
"""Load cookies from JSON data.
|
172
|
+
|
173
|
+
Args:
|
174
|
+
data: JSON string containing cookies data
|
175
|
+
|
176
|
+
Returns:
|
177
|
+
True if loaded successfully, False otherwise
|
178
|
+
"""
|
179
|
+
if not data:
|
180
|
+
return False
|
181
|
+
|
182
|
+
parsed_data = json.loads(data)
|
183
|
+
return self._load_cookies_from_dict(parsed_data)
|
184
|
+
|
185
|
+
def _load_cookies_from_dict(self, data: dict) -> bool:
|
186
|
+
"""Load cookies from dictionary data.
|
187
|
+
|
188
|
+
Args:
|
189
|
+
data: Dictionary containing cookies data
|
190
|
+
|
191
|
+
Returns:
|
192
|
+
True if loaded successfully
|
193
|
+
"""
|
194
|
+
self._version = data.get('version', 0)
|
195
|
+
cookies_data = data.get('cookies', [])
|
196
|
+
|
197
|
+
self._cookies.clear()
|
198
|
+
for cookie_dict in cookies_data:
|
199
|
+
cookie = self.dict_to_cookie(cookie_dict)
|
200
|
+
if cookie:
|
201
|
+
key = self.make_key(cookie)
|
202
|
+
self._cookies[key] = cookie
|
203
|
+
|
204
|
+
return True
|
205
|
+
|
206
|
+
def _prepare_cookies_data(self) -> dict:
|
207
|
+
"""Prepare cookies data for saving.
|
208
|
+
|
209
|
+
Returns:
|
210
|
+
Dictionary with version, cookies, and timestamp
|
211
|
+
"""
|
212
|
+
cookies = getattr(self, '_cookies', {})
|
213
|
+
version = getattr(self, '_version', 0)
|
214
|
+
|
215
|
+
cookies_data = [
|
216
|
+
self.cookie_to_dict(cookie)
|
217
|
+
for cookie in cookies.values()
|
218
|
+
if not cookie.discard # Only save persistent cookies
|
219
|
+
]
|
220
|
+
|
221
|
+
return {
|
222
|
+
'version': version,
|
223
|
+
'cookies': cookies_data,
|
224
|
+
'saved_at': datetime.now(timezone.utc).isoformat(),
|
225
|
+
}
|
226
|
+
|
227
|
+
def _increment_file_version(self) -> int:
|
228
|
+
"""Increment version and save to file."""
|
229
|
+
version_path = getattr(self, 'version_path', None)
|
230
|
+
if not version_path:
|
231
|
+
return 0
|
232
|
+
|
233
|
+
self._version = self._get_file_version() + 1
|
234
|
+
|
235
|
+
try:
|
236
|
+
version_path.parent.mkdir(parents=True, exist_ok=True)
|
237
|
+
with version_path.open('w', encoding='utf-8') as f:
|
238
|
+
f.write(str(self._version))
|
239
|
+
except OSError:
|
240
|
+
log.exception('Failed to save version')
|
241
|
+
|
242
|
+
return self._version
|
243
|
+
|
244
|
+
|
245
|
+
class FileCookieManager(CookieManagerMixin, CookieManager):
|
246
|
+
"""File-based cookie manager implementation."""
|
247
|
+
|
248
|
+
def __init__(self, file_path: str):
|
249
|
+
self.file_path = Path(file_path)
|
250
|
+
self.lock_path = Path(f'{file_path}.lock')
|
251
|
+
self.version_path = Path(f'{file_path}.version')
|
252
|
+
self._cookies: dict[str, Cookie] = {}
|
253
|
+
self._version: int = 0
|
254
|
+
self._lock = threading.Lock()
|
255
|
+
self._lock_fd: int | None = None
|
256
|
+
|
257
|
+
# Automatically load cookies if file exists
|
258
|
+
if self.file_path.exists():
|
259
|
+
try:
|
260
|
+
self.load_cookies()
|
261
|
+
except (OSError, json.JSONDecodeError, ValueError):
|
262
|
+
# Log error but don't fail initialization
|
263
|
+
log.exception(
|
264
|
+
'Failed to load cookies from %s during initialization', self.file_path
|
265
|
+
)
|
266
|
+
|
267
|
+
@property
|
268
|
+
def version(self) -> int:
|
269
|
+
"""Get current version."""
|
270
|
+
return self._version
|
271
|
+
|
272
|
+
def load_cookies(self) -> bool:
|
273
|
+
"""Load cookies from file."""
|
274
|
+
with self._lock:
|
275
|
+
if not self.file_path.exists():
|
276
|
+
return False
|
277
|
+
|
278
|
+
try:
|
279
|
+
with self.file_path.open(encoding='utf-8') as f:
|
280
|
+
data = json.load(f)
|
281
|
+
|
282
|
+
self._load_cookies_from_dict(data)
|
283
|
+
|
284
|
+
except (OSError, json.JSONDecodeError) as e:
|
285
|
+
log.warning('Failed to load cookies from %s: %s', self.file_path, e)
|
286
|
+
return False
|
287
|
+
return True
|
288
|
+
|
289
|
+
def save_cookies(self) -> None:
|
290
|
+
"""Save cookies to file."""
|
291
|
+
with self._lock:
|
292
|
+
data = self._prepare_cookies_data()
|
293
|
+
|
294
|
+
# Ensure directory exists
|
295
|
+
self.file_path.parent.mkdir(parents=True, exist_ok=True)
|
296
|
+
|
297
|
+
# Write to temp file first
|
298
|
+
temp_path = Path(f'{self.file_path}.tmp')
|
299
|
+
try:
|
300
|
+
with temp_path.open('w', encoding='utf-8') as f:
|
301
|
+
json.dump(data, f, indent=2)
|
302
|
+
# Atomic rename
|
303
|
+
temp_path.rename(self.file_path)
|
304
|
+
except (OSError, TypeError, ValueError):
|
305
|
+
log.exception('Failed to save cookies to %s', self.file_path)
|
306
|
+
if temp_path.exists():
|
307
|
+
temp_path.unlink()
|
308
|
+
raise
|
309
|
+
|
310
|
+
def add_cookie(self, cookie: Cookie) -> None:
|
311
|
+
"""Add or update a cookie."""
|
312
|
+
with self._lock:
|
313
|
+
key = self.make_key(cookie)
|
314
|
+
self._cookies[key] = cookie
|
315
|
+
self._version = self._increment_version()
|
316
|
+
|
317
|
+
def get_cookie(self, domain: str, path: str, name: str) -> Cookie | None:
|
318
|
+
"""Get a specific cookie."""
|
319
|
+
with self._lock:
|
320
|
+
key = f'{domain}:{path}:{name}'
|
321
|
+
return self._cookies.get(key)
|
322
|
+
|
323
|
+
def get_all_cookies(self) -> list[Cookie]:
|
324
|
+
"""Get all cookies."""
|
325
|
+
with self._lock:
|
326
|
+
return list(self._cookies.values())
|
327
|
+
|
328
|
+
def clear_cookies(self) -> None:
|
329
|
+
"""Clear all cookies."""
|
330
|
+
with self._lock:
|
331
|
+
self._cookies.clear()
|
332
|
+
self._version = self._increment_version()
|
333
|
+
|
334
|
+
def update_from_jar(self, cookie_jar: CookieJar) -> None:
|
335
|
+
"""Update cookies from a CookieJar."""
|
336
|
+
with self._lock:
|
337
|
+
for cookie in cookie_jar:
|
338
|
+
key = self.make_key(cookie)
|
339
|
+
self._cookies[key] = cookie
|
340
|
+
self._version = self._increment_version()
|
341
|
+
|
342
|
+
def acquire_auth_lock(self, timeout: float = 30.0) -> bool:
|
343
|
+
"""Acquire file lock with timeout."""
|
344
|
+
start_time = time.time()
|
345
|
+
|
346
|
+
while time.time() - start_time < timeout:
|
347
|
+
try:
|
348
|
+
# Try to create lock file exclusively
|
349
|
+
self._lock_fd = os.open(
|
350
|
+
str(self.lock_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644
|
351
|
+
)
|
352
|
+
except FileExistsError:
|
353
|
+
# Lock exists, check if it's stale
|
354
|
+
if self._is_lock_stale():
|
355
|
+
self._remove_stale_lock()
|
356
|
+
continue
|
357
|
+
time.sleep(0.1)
|
358
|
+
continue
|
359
|
+
# Write PID for debugging
|
360
|
+
os.write(self._lock_fd, str(os.getpid()).encode())
|
361
|
+
return True
|
362
|
+
|
363
|
+
return False
|
364
|
+
|
365
|
+
def release_auth_lock(self) -> None:
|
366
|
+
"""Release file lock."""
|
367
|
+
if self._lock_fd is not None:
|
368
|
+
try:
|
369
|
+
os.close(self._lock_fd)
|
370
|
+
self.lock_path.unlink(missing_ok=True)
|
371
|
+
except OSError as e:
|
372
|
+
log.warning('Failed to release lock: %s', e)
|
373
|
+
finally:
|
374
|
+
self._lock_fd = None
|
375
|
+
|
376
|
+
def check_version(self) -> bool:
|
377
|
+
"""Check if our version matches file version."""
|
378
|
+
current_version = self._get_file_version()
|
379
|
+
return current_version == self._version
|
380
|
+
|
381
|
+
def refresh_if_needed(self) -> bool:
|
382
|
+
"""Reload cookies if version changed."""
|
383
|
+
if not self.check_version():
|
384
|
+
log.info('Cookie version mismatch, reloading...')
|
385
|
+
return self.load_cookies()
|
386
|
+
return True
|
387
|
+
|
388
|
+
def handle_auth_error(self, retry_callback: Callable[..., T], *args: Any, **kwargs: Any) -> T:
|
389
|
+
"""Handle authentication error with version check and retry.
|
390
|
+
|
391
|
+
Args:
|
392
|
+
retry_callback: Function to call for retry
|
393
|
+
*args, **kwargs: Arguments to pass to retry_callback
|
394
|
+
|
395
|
+
Returns:
|
396
|
+
Result of retry_callback if successful
|
397
|
+
|
398
|
+
Raises:
|
399
|
+
Original exception if all retries fail
|
400
|
+
"""
|
401
|
+
log.info('Authentication error occurred, checking cookie version...')
|
402
|
+
|
403
|
+
# First, check if our cookies are outdated
|
404
|
+
if not self.check_version():
|
405
|
+
log.info('Cookie version outdated, reloading...')
|
406
|
+
if self.load_cookies():
|
407
|
+
log.info('Cookies reloaded, retrying with updated cookies...')
|
408
|
+
try:
|
409
|
+
return retry_callback(*args, **kwargs)
|
410
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
411
|
+
log.warning('Retry with updated cookies failed: %s', e)
|
412
|
+
else:
|
413
|
+
log.warning('Failed to reload cookies')
|
414
|
+
|
415
|
+
# If still failing, acquire lock and re-authenticate
|
416
|
+
log.info('Attempting re-authentication...')
|
417
|
+
if self.acquire_auth_lock(timeout=30.0):
|
418
|
+
try:
|
419
|
+
# Clear existing cookies
|
420
|
+
self.clear_cookies()
|
421
|
+
self.save_cookies()
|
422
|
+
|
423
|
+
# Call retry which should trigger re-authentication
|
424
|
+
result = retry_callback(*args, **kwargs)
|
425
|
+
|
426
|
+
# Save new cookies after successful auth
|
427
|
+
self.save_cookies()
|
428
|
+
return result
|
429
|
+
|
430
|
+
finally:
|
431
|
+
self.release_auth_lock()
|
432
|
+
msg = 'Failed to acquire authentication lock for re-authentication'
|
433
|
+
raise PararamioException(msg)
|
434
|
+
|
435
|
+
def _increment_version(self) -> int:
|
436
|
+
"""Increment version and save to file."""
|
437
|
+
return self._increment_file_version()
|
438
|
+
|
439
|
+
def _is_lock_stale(self, max_age: float = 300.0) -> bool:
|
440
|
+
"""Check if lock file is stale (older than max_age seconds)."""
|
441
|
+
try:
|
442
|
+
stat = self.lock_path.stat()
|
443
|
+
except FileNotFoundError:
|
444
|
+
return False
|
445
|
+
age = time.time() - stat.st_mtime
|
446
|
+
return age > max_age
|
447
|
+
|
448
|
+
def _remove_stale_lock(self) -> None:
|
449
|
+
"""Remove stale lock file."""
|
450
|
+
try:
|
451
|
+
self.lock_path.unlink()
|
452
|
+
log.info('Removed stale lock file: %s', self.lock_path)
|
453
|
+
except FileNotFoundError:
|
454
|
+
pass
|
455
|
+
|
456
|
+
|
457
|
+
class RedisCookieManager(CookieManagerMixin, CookieManager):
|
458
|
+
"""Redis-based cookie manager implementation."""
|
459
|
+
|
460
|
+
def __init__(self, redis_client: Any, key_prefix: str = 'pararamio:cookies'):
|
461
|
+
self.redis = redis_client
|
462
|
+
self.key_prefix = key_prefix
|
463
|
+
self.data_key = f'{key_prefix}:data'
|
464
|
+
self.lock_key = f'{key_prefix}:lock'
|
465
|
+
self.version_key = f'{key_prefix}:version'
|
466
|
+
self._cookies: dict[str, Cookie] = {}
|
467
|
+
self._version: int = 0
|
468
|
+
self._lock = threading.Lock()
|
469
|
+
self._lock_token: str | None = None
|
470
|
+
|
471
|
+
@property
|
472
|
+
def version(self) -> int:
|
473
|
+
"""Get current version."""
|
474
|
+
return self._version
|
475
|
+
|
476
|
+
def load_cookies(self) -> bool:
|
477
|
+
"""Load cookies from Redis."""
|
478
|
+
with self._lock:
|
479
|
+
try:
|
480
|
+
data = self.redis.get(self.data_key)
|
481
|
+
return self._load_cookies_from_json(data)
|
482
|
+
except (json.JSONDecodeError, KeyError, TypeError, AttributeError) as e:
|
483
|
+
log.warning('Failed to load cookies from Redis: %s', e)
|
484
|
+
return False
|
485
|
+
|
486
|
+
def save_cookies(self) -> None:
|
487
|
+
"""Save cookies to Redis."""
|
488
|
+
with self._lock:
|
489
|
+
data = self._prepare_cookies_data()
|
490
|
+
|
491
|
+
try:
|
492
|
+
self.redis.set(self.data_key, json.dumps(data))
|
493
|
+
except (OSError, TypeError, ValueError, AttributeError):
|
494
|
+
log.exception('Failed to save cookies to Redis')
|
495
|
+
raise
|
496
|
+
|
497
|
+
def add_cookie(self, cookie: Cookie) -> None:
|
498
|
+
"""Add or update a cookie."""
|
499
|
+
with self._lock:
|
500
|
+
key = self.make_key(cookie)
|
501
|
+
self._cookies[key] = cookie
|
502
|
+
self._version = self._increment_version()
|
503
|
+
|
504
|
+
def get_cookie(self, domain: str, path: str, name: str) -> Cookie | None:
|
505
|
+
"""Get a specific cookie."""
|
506
|
+
with self._lock:
|
507
|
+
key = f'{domain}:{path}:{name}'
|
508
|
+
return self._cookies.get(key)
|
509
|
+
|
510
|
+
def get_all_cookies(self) -> list[Cookie]:
|
511
|
+
"""Get all cookies."""
|
512
|
+
with self._lock:
|
513
|
+
return list(self._cookies.values())
|
514
|
+
|
515
|
+
def clear_cookies(self) -> None:
|
516
|
+
"""Clear all cookies."""
|
517
|
+
with self._lock:
|
518
|
+
self._cookies.clear()
|
519
|
+
self._version = self._increment_version()
|
520
|
+
|
521
|
+
def update_from_jar(self, cookie_jar: CookieJar) -> None:
|
522
|
+
"""Update cookies from a CookieJar."""
|
523
|
+
with self._lock:
|
524
|
+
for cookie in cookie_jar:
|
525
|
+
key = self.make_key(cookie)
|
526
|
+
self._cookies[key] = cookie
|
527
|
+
self._version = self._increment_version()
|
528
|
+
|
529
|
+
def acquire_auth_lock(self, timeout: float = 30.0) -> bool:
|
530
|
+
"""Acquire distributed lock using Redis."""
|
531
|
+
self._lock_token = str(uuid.uuid4())
|
532
|
+
|
533
|
+
start_time = time.time()
|
534
|
+
while time.time() - start_time < timeout:
|
535
|
+
# Try to set lock with expiration
|
536
|
+
if self.redis.set(self.lock_key, self._lock_token, nx=True, ex=300):
|
537
|
+
return True
|
538
|
+
time.sleep(0.1)
|
539
|
+
|
540
|
+
return False
|
541
|
+
|
542
|
+
def release_auth_lock(self) -> None:
|
543
|
+
"""Release distributed lock."""
|
544
|
+
if self._lock_token:
|
545
|
+
# Use Lua script for atomic check-and-delete
|
546
|
+
lua_script = """
|
547
|
+
if redis.call("get", KEYS[1]) == ARGV[1] then
|
548
|
+
return redis.call("del", KEYS[1])
|
549
|
+
else
|
550
|
+
return 0
|
551
|
+
end
|
552
|
+
"""
|
553
|
+
try:
|
554
|
+
self.redis.eval(lua_script, 1, self.lock_key, self._lock_token)
|
555
|
+
except (AttributeError, KeyError) as e:
|
556
|
+
log.warning('Failed to release Redis lock: %s', e)
|
557
|
+
finally:
|
558
|
+
self._lock_token = None
|
559
|
+
|
560
|
+
def check_version(self) -> bool:
|
561
|
+
"""Check if our version matches Redis version."""
|
562
|
+
try:
|
563
|
+
version = self.redis.get(self.version_key)
|
564
|
+
except (ValueError, AttributeError, TypeError, KeyError):
|
565
|
+
return True
|
566
|
+
current_version = int(version) if version else 0
|
567
|
+
return current_version == self._version
|
568
|
+
|
569
|
+
def refresh_if_needed(self) -> bool:
|
570
|
+
"""Reload cookies if version changed."""
|
571
|
+
if not self.check_version():
|
572
|
+
log.info('Cookie version mismatch, reloading...')
|
573
|
+
return self.load_cookies()
|
574
|
+
return True
|
575
|
+
|
576
|
+
def handle_auth_error(self, retry_callback: Callable[..., T], *args: Any, **kwargs: Any) -> T:
|
577
|
+
"""Handle authentication error with version check and retry."""
|
578
|
+
log.info('Authentication error occurred (Redis), checking cookie version...')
|
579
|
+
|
580
|
+
# First, check if our cookies are outdated
|
581
|
+
if not self.check_version():
|
582
|
+
log.info('Cookie version outdated, reloading from Redis...')
|
583
|
+
if self.load_cookies():
|
584
|
+
log.info('Cookies reloaded, retrying with updated cookies...')
|
585
|
+
try:
|
586
|
+
return retry_callback(*args, **kwargs)
|
587
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
588
|
+
log.warning('Retry with updated cookies failed: %s', e)
|
589
|
+
else:
|
590
|
+
log.warning('Failed to reload cookies from Redis')
|
591
|
+
|
592
|
+
# If still failing, acquire distributed lock and re-authenticate
|
593
|
+
log.info('Attempting re-authentication with distributed lock...')
|
594
|
+
if self.acquire_auth_lock(timeout=30.0):
|
595
|
+
try:
|
596
|
+
# Clear existing cookies
|
597
|
+
self.clear_cookies()
|
598
|
+
self.save_cookies()
|
599
|
+
|
600
|
+
# Call retry which should trigger re-authentication
|
601
|
+
result = retry_callback(*args, **kwargs)
|
602
|
+
|
603
|
+
# Save new cookies after successful auth
|
604
|
+
self.save_cookies()
|
605
|
+
return result
|
606
|
+
|
607
|
+
finally:
|
608
|
+
self.release_auth_lock()
|
609
|
+
else:
|
610
|
+
msg = 'Failed to acquire distributed lock for re-authentication'
|
611
|
+
raise PararamioException(msg)
|
612
|
+
|
613
|
+
def _increment_version(self) -> int:
|
614
|
+
"""Atomically increment version in Redis."""
|
615
|
+
try:
|
616
|
+
self._version = self.redis.incr(self.version_key)
|
617
|
+
except (AttributeError, TypeError):
|
618
|
+
log.exception('Failed to increment version in Redis')
|
619
|
+
return self._version
|
620
|
+
return self._version
|
621
|
+
|
622
|
+
|
623
|
+
class InMemoryCookieManager(CookieManagerMixin, CookieManager):
|
624
|
+
"""In-memory cookie manager implementation (no persistence)."""
|
625
|
+
|
626
|
+
def __init__(self):
|
627
|
+
self._cookies: dict[str, Cookie] = {}
|
628
|
+
self._version: int = 0
|
629
|
+
self._lock = threading.Lock()
|
630
|
+
|
631
|
+
@property
|
632
|
+
def version(self) -> int:
|
633
|
+
"""Get current version."""
|
634
|
+
return self._version
|
635
|
+
|
636
|
+
def load_cookies(self) -> bool:
|
637
|
+
"""No-op for in-memory manager."""
|
638
|
+
# Cookies are already in memory
|
639
|
+
return bool(self._cookies)
|
640
|
+
|
641
|
+
def save_cookies(self) -> None:
|
642
|
+
"""No-op for in-memory manager."""
|
643
|
+
# Cookies are already in memory, nothing to save
|
644
|
+
|
645
|
+
def add_cookie(self, cookie: Cookie) -> None:
|
646
|
+
"""Add or update a cookie."""
|
647
|
+
with self._lock:
|
648
|
+
key = self.make_key(cookie)
|
649
|
+
self._cookies[key] = cookie
|
650
|
+
self._version += 1
|
651
|
+
|
652
|
+
def get_cookie(self, domain: str, path: str, name: str) -> Cookie | None:
|
653
|
+
"""Get a specific cookie."""
|
654
|
+
with self._lock:
|
655
|
+
key = f'{domain}:{path}:{name}'
|
656
|
+
return self._cookies.get(key)
|
657
|
+
|
658
|
+
def get_all_cookies(self) -> list[Cookie]:
|
659
|
+
"""Get all cookies."""
|
660
|
+
with self._lock:
|
661
|
+
return list(self._cookies.values())
|
662
|
+
|
663
|
+
def clear_cookies(self) -> None:
|
664
|
+
"""Clear all cookies."""
|
665
|
+
with self._lock:
|
666
|
+
self._cookies.clear()
|
667
|
+
self._version += 1
|
668
|
+
|
669
|
+
def update_from_jar(self, cookie_jar: CookieJar) -> None:
|
670
|
+
"""Update cookies from a CookieJar."""
|
671
|
+
with self._lock:
|
672
|
+
for cookie in cookie_jar:
|
673
|
+
key = self.make_key(cookie)
|
674
|
+
self._cookies[key] = cookie
|
675
|
+
self._version += 1
|
676
|
+
|
677
|
+
def acquire_auth_lock(self, timeout: float = 30.0) -> bool: # noqa: ARG002
|
678
|
+
"""Always returns True for in-memory manager."""
|
679
|
+
return True
|
680
|
+
|
681
|
+
def release_auth_lock(self) -> None:
|
682
|
+
"""No-op for in-memory manager."""
|
683
|
+
|
684
|
+
def check_version(self) -> bool:
|
685
|
+
"""Always returns True for in-memory manager."""
|
686
|
+
return True
|
687
|
+
|
688
|
+
def refresh_if_needed(self) -> bool:
|
689
|
+
"""No-op for in-memory manager."""
|
690
|
+
return True
|
691
|
+
|
692
|
+
def handle_auth_error(self, retry_callback: Callable[..., T], *args: Any, **kwargs: Any) -> T:
|
693
|
+
"""Handle authentication error by retrying."""
|
694
|
+
log.info('Authentication error occurred (in-memory), retrying...')
|
695
|
+
|
696
|
+
# For in-memory, just clear cookies and retry
|
697
|
+
self.clear_cookies()
|
698
|
+
|
699
|
+
try:
|
700
|
+
return retry_callback(*args, **kwargs)
|
701
|
+
except (AttributeError, TypeError):
|
702
|
+
log.exception('Retry failed')
|
703
|
+
raise
|
704
|
+
|
705
|
+
|
706
|
+
# Async versions
|
707
|
+
class AsyncCookieManager(ABC):
|
708
|
+
"""Abstract async cookie manager."""
|
709
|
+
|
710
|
+
@abstractmethod
|
711
|
+
async def load_cookies(self) -> bool:
|
712
|
+
"""Load cookies from storage asynchronously."""
|
713
|
+
|
714
|
+
@abstractmethod
|
715
|
+
async def save_cookies(self) -> None:
|
716
|
+
"""Save cookies to storage asynchronously."""
|
717
|
+
|
718
|
+
@abstractmethod
|
719
|
+
async def acquire_auth_lock(self, timeout: float = 30.0) -> bool:
|
720
|
+
"""Acquire lock for authentication process asynchronously."""
|
721
|
+
|
722
|
+
@abstractmethod
|
723
|
+
async def release_auth_lock(self) -> None:
|
724
|
+
"""Release authentication lock asynchronously."""
|
725
|
+
|
726
|
+
@abstractmethod
|
727
|
+
async def handle_auth_error(
|
728
|
+
self, retry_callback: Callable[..., T], *args: Any, **kwargs: Any
|
729
|
+
) -> T:
|
730
|
+
"""Handle authentication error with version check and retry asynchronously."""
|
731
|
+
|
732
|
+
|
733
|
+
class AsyncFileCookieManager(CookieManagerMixin, AsyncCookieManager):
|
734
|
+
"""Async file-based cookie manager."""
|
735
|
+
|
736
|
+
def __init__(self, file_path: str):
|
737
|
+
self.file_path = Path(file_path)
|
738
|
+
self.lock_path = Path(f'{file_path}.lock')
|
739
|
+
self.version_path = Path(f'{file_path}.version')
|
740
|
+
self._cookies: dict[str, Cookie] = {}
|
741
|
+
self._version: int = 0
|
742
|
+
self._lock = asyncio.Lock()
|
743
|
+
self._lock_fd: int | None = None
|
744
|
+
|
745
|
+
# Automatically load cookies synchronously if file exists
|
746
|
+
if self.file_path.exists():
|
747
|
+
try:
|
748
|
+
# Use synchronous file reading for initialization
|
749
|
+
with self.file_path.open(encoding='utf-8') as f:
|
750
|
+
data = json.load(f)
|
751
|
+
self._load_cookies_from_dict(data)
|
752
|
+
# Try to load version
|
753
|
+
if self.version_path.exists():
|
754
|
+
with self.version_path.open(encoding='utf-8') as f:
|
755
|
+
self._version = int(f.read().strip())
|
756
|
+
except (OSError, json.JSONDecodeError, ValueError):
|
757
|
+
# Log error but don't fail initialization
|
758
|
+
log.exception(
|
759
|
+
'Failed to load cookies from %s during initialization', self.file_path
|
760
|
+
)
|
761
|
+
|
762
|
+
@property
|
763
|
+
def version(self) -> int:
|
764
|
+
"""Get current version."""
|
765
|
+
return self._version
|
766
|
+
|
767
|
+
async def load_cookies(self) -> bool:
|
768
|
+
"""Load cookies from file asynchronously."""
|
769
|
+
async with self._lock:
|
770
|
+
if not self.file_path.exists():
|
771
|
+
return False
|
772
|
+
|
773
|
+
try:
|
774
|
+
loop = asyncio.get_event_loop()
|
775
|
+
read_func = functools.partial(self.file_path.read_text, encoding='utf-8')
|
776
|
+
content = await loop.run_in_executor(None, read_func)
|
777
|
+
data = json.loads(content)
|
778
|
+
|
779
|
+
self._load_cookies_from_dict(data)
|
780
|
+
|
781
|
+
except (OSError, json.JSONDecodeError) as e:
|
782
|
+
log.warning('Failed to load cookies from %s: %s', self.file_path, e)
|
783
|
+
return False
|
784
|
+
return True
|
785
|
+
|
786
|
+
async def save_cookies(self) -> None:
|
787
|
+
"""Save cookies to file asynchronously."""
|
788
|
+
async with self._lock:
|
789
|
+
data = self._prepare_cookies_data()
|
790
|
+
|
791
|
+
# Ensure directory exists
|
792
|
+
self.file_path.parent.mkdir(parents=True, exist_ok=True)
|
793
|
+
|
794
|
+
# Write to temp file first
|
795
|
+
temp_path = Path(f'{self.file_path}.tmp')
|
796
|
+
try:
|
797
|
+
loop = asyncio.get_event_loop()
|
798
|
+
content = json.dumps(data, indent=2)
|
799
|
+
# Use functools.partial to bind the arguments
|
800
|
+
write_func = functools.partial(temp_path.write_text, content, encoding='utf-8')
|
801
|
+
await loop.run_in_executor(None, write_func)
|
802
|
+
# Atomic rename
|
803
|
+
await loop.run_in_executor(None, temp_path.rename, self.file_path)
|
804
|
+
except (OSError, TypeError, ValueError):
|
805
|
+
log.exception('Failed to save cookies to %s', self.file_path)
|
806
|
+
if temp_path.exists():
|
807
|
+
temp_path.unlink()
|
808
|
+
raise
|
809
|
+
|
810
|
+
def add_cookie(self, cookie: Cookie) -> None:
|
811
|
+
"""Add or update a cookie."""
|
812
|
+
key = self.make_key(cookie)
|
813
|
+
self._cookies[key] = cookie
|
814
|
+
self._version = self._increment_version()
|
815
|
+
|
816
|
+
def get_cookie(self, domain: str, path: str, name: str) -> Cookie | None:
|
817
|
+
"""Get a specific cookie."""
|
818
|
+
key = f'{domain}:{path}:{name}'
|
819
|
+
return self._cookies.get(key)
|
820
|
+
|
821
|
+
def get_all_cookies(self) -> list[Cookie]:
|
822
|
+
"""Get all cookies."""
|
823
|
+
return list(self._cookies.values())
|
824
|
+
|
825
|
+
def clear_cookies(self) -> None:
|
826
|
+
"""Clear all cookies."""
|
827
|
+
self._cookies.clear()
|
828
|
+
self._version = self._increment_version()
|
829
|
+
|
830
|
+
def update_from_jar(self, cookie_jar: CookieJar) -> None:
|
831
|
+
"""Update cookies from a CookieJar."""
|
832
|
+
for cookie in cookie_jar:
|
833
|
+
key = self.make_key(cookie)
|
834
|
+
self._cookies[key] = cookie
|
835
|
+
self._version = self._increment_version()
|
836
|
+
|
837
|
+
async def acquire_auth_lock(self, timeout: float = 30.0) -> bool:
|
838
|
+
"""Acquire file lock asynchronously."""
|
839
|
+
start_time = time.time()
|
840
|
+
loop = asyncio.get_event_loop()
|
841
|
+
|
842
|
+
while time.time() - start_time < timeout:
|
843
|
+
try:
|
844
|
+
# Try to create lock file exclusively
|
845
|
+
self._lock_fd = await loop.run_in_executor(
|
846
|
+
None, os.open, str(self.lock_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644
|
847
|
+
)
|
848
|
+
except FileExistsError:
|
849
|
+
# Lock exists, check if it's stale
|
850
|
+
if await self._is_lock_stale():
|
851
|
+
await self._remove_stale_lock()
|
852
|
+
continue
|
853
|
+
await asyncio.sleep(0.1)
|
854
|
+
continue
|
855
|
+
# Write PID for debugging
|
856
|
+
await loop.run_in_executor(None, os.write, self._lock_fd, str(os.getpid()).encode())
|
857
|
+
return True
|
858
|
+
|
859
|
+
return False
|
860
|
+
|
861
|
+
async def release_auth_lock(self) -> None:
|
862
|
+
"""Release file lock asynchronously."""
|
863
|
+
if self._lock_fd is not None:
|
864
|
+
try:
|
865
|
+
loop = asyncio.get_event_loop()
|
866
|
+
await loop.run_in_executor(None, os.close, self._lock_fd)
|
867
|
+
self.lock_path.unlink(missing_ok=True)
|
868
|
+
except OSError as e:
|
869
|
+
log.warning('Failed to release lock: %s', e)
|
870
|
+
finally:
|
871
|
+
self._lock_fd = None
|
872
|
+
|
873
|
+
def check_version(self) -> bool:
|
874
|
+
"""Check if our version matches file version."""
|
875
|
+
current_version = self._get_file_version()
|
876
|
+
return current_version == self._version
|
877
|
+
|
878
|
+
def refresh_if_needed(self) -> bool:
|
879
|
+
"""Reload cookies if version changed."""
|
880
|
+
# This needs to be async but is called from sync context
|
881
|
+
# For now, return False to indicate async operation needed
|
882
|
+
return not self.check_version()
|
883
|
+
|
884
|
+
async def refresh_if_needed_async(self) -> bool:
|
885
|
+
"""Reload cookies if version changed (async version)."""
|
886
|
+
if not self.check_version():
|
887
|
+
log.info('Cookie version mismatch, reloading...')
|
888
|
+
return await self.load_cookies()
|
889
|
+
return True
|
890
|
+
|
891
|
+
async def handle_auth_error(
|
892
|
+
self, retry_callback: Callable[..., T], *args: Any, **kwargs: Any
|
893
|
+
) -> T:
|
894
|
+
"""Handle authentication error with version check and retry asynchronously."""
|
895
|
+
log.info('Authentication error occurred (async), checking cookie version...')
|
896
|
+
|
897
|
+
# First, check if our cookies are outdated
|
898
|
+
if not self.check_version():
|
899
|
+
log.info('Cookie version outdated, reloading...')
|
900
|
+
if await self.load_cookies():
|
901
|
+
log.info('Cookies reloaded, retrying with updated cookies...')
|
902
|
+
try:
|
903
|
+
return await retry_callback(*args, **kwargs)
|
904
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
905
|
+
log.warning('Retry with updated cookies failed: %s', e)
|
906
|
+
else:
|
907
|
+
log.warning('Failed to reload cookies')
|
908
|
+
|
909
|
+
# If still failing, acquire lock and re-authenticate
|
910
|
+
log.info('Attempting re-authentication...')
|
911
|
+
if await self.acquire_auth_lock(timeout=30.0):
|
912
|
+
try:
|
913
|
+
# Clear existing cookies
|
914
|
+
self.clear_cookies()
|
915
|
+
await self.save_cookies()
|
916
|
+
|
917
|
+
# Call retry which should trigger re-authentication
|
918
|
+
result = await retry_callback(*args, **kwargs)
|
919
|
+
|
920
|
+
# Save new cookies after successful auth
|
921
|
+
await self.save_cookies()
|
922
|
+
return result
|
923
|
+
|
924
|
+
finally:
|
925
|
+
await self.release_auth_lock()
|
926
|
+
msg = 'Failed to acquire authentication lock for re-authentication'
|
927
|
+
raise PararamioException(msg)
|
928
|
+
|
929
|
+
def _increment_version(self) -> int:
|
930
|
+
"""Increment version and save to file."""
|
931
|
+
return self._increment_file_version()
|
932
|
+
|
933
|
+
async def _is_lock_stale(self, max_age: float = 300.0) -> bool:
|
934
|
+
"""Check if lock file is stale (older than max_age seconds)."""
|
935
|
+
try:
|
936
|
+
stat = self.lock_path.stat()
|
937
|
+
except FileNotFoundError:
|
938
|
+
return False
|
939
|
+
age = time.time() - stat.st_mtime
|
940
|
+
return age > max_age
|
941
|
+
|
942
|
+
async def _remove_stale_lock(self) -> None:
|
943
|
+
"""Remove stale lock file."""
|
944
|
+
try:
|
945
|
+
self.lock_path.unlink()
|
946
|
+
log.info('Removed stale lock file: %s', self.lock_path)
|
947
|
+
except FileNotFoundError:
|
948
|
+
pass
|
949
|
+
|
950
|
+
|
951
|
+
class AsyncRedisCookieManager(CookieManagerMixin, AsyncCookieManager):
|
952
|
+
"""Async Redis-based cookie manager."""
|
953
|
+
|
954
|
+
def __init__(self, redis_client: Any, key_prefix: str = 'pararamio:cookies'):
|
955
|
+
self.redis = redis_client
|
956
|
+
self.key_prefix = key_prefix
|
957
|
+
self.data_key = f'{key_prefix}:data'
|
958
|
+
self.lock_key = f'{key_prefix}:lock'
|
959
|
+
self.version_key = f'{key_prefix}:version'
|
960
|
+
self._cookies: dict[str, Cookie] = {}
|
961
|
+
self._version: int = 0
|
962
|
+
self._lock = asyncio.Lock()
|
963
|
+
self._lock_token: str | None = None
|
964
|
+
|
965
|
+
@property
|
966
|
+
def version(self) -> int:
|
967
|
+
"""Get current version."""
|
968
|
+
return self._version
|
969
|
+
|
970
|
+
async def load_cookies(self) -> bool:
|
971
|
+
"""Load cookies from Redis asynchronously."""
|
972
|
+
async with self._lock:
|
973
|
+
# Assuming redis_client is async
|
974
|
+
try:
|
975
|
+
data = await self.redis.get(self.data_key)
|
976
|
+
return self._load_cookies_from_json(data)
|
977
|
+
except (json.JSONDecodeError, KeyError, TypeError, AttributeError) as e:
|
978
|
+
log.warning('Failed to load cookies from Redis: %s', e)
|
979
|
+
return False
|
980
|
+
|
981
|
+
async def save_cookies(self) -> None:
|
982
|
+
"""Save cookies to Redis asynchronously."""
|
983
|
+
async with self._lock:
|
984
|
+
data = self._prepare_cookies_data()
|
985
|
+
|
986
|
+
try:
|
987
|
+
await self.redis.set(self.data_key, json.dumps(data))
|
988
|
+
except (OSError, TypeError, ValueError, AttributeError):
|
989
|
+
log.exception('Failed to save cookies to Redis')
|
990
|
+
raise
|
991
|
+
|
992
|
+
def add_cookie(self, cookie: Cookie) -> None:
|
993
|
+
"""Add or update a cookie."""
|
994
|
+
key = self.make_key(cookie)
|
995
|
+
self._cookies[key] = cookie
|
996
|
+
self._version = self._increment_version()
|
997
|
+
|
998
|
+
def get_cookie(self, domain: str, path: str, name: str) -> Cookie | None:
|
999
|
+
"""Get a specific cookie."""
|
1000
|
+
key = f'{domain}:{path}:{name}'
|
1001
|
+
return self._cookies.get(key)
|
1002
|
+
|
1003
|
+
def get_all_cookies(self) -> list[Cookie]:
|
1004
|
+
"""Get all cookies."""
|
1005
|
+
return list(self._cookies.values())
|
1006
|
+
|
1007
|
+
def clear_cookies(self) -> None:
|
1008
|
+
"""Clear all cookies."""
|
1009
|
+
self._cookies.clear()
|
1010
|
+
self._version = self._increment_version()
|
1011
|
+
|
1012
|
+
def update_from_jar(self, cookie_jar: CookieJar) -> None:
|
1013
|
+
"""Update cookies from a CookieJar."""
|
1014
|
+
for cookie in cookie_jar:
|
1015
|
+
key = self.make_key(cookie)
|
1016
|
+
self._cookies[key] = cookie
|
1017
|
+
self._version = self._increment_version()
|
1018
|
+
|
1019
|
+
def check_version(self) -> bool:
|
1020
|
+
"""Check if our version matches Redis version."""
|
1021
|
+
try:
|
1022
|
+
loop = asyncio.get_event_loop()
|
1023
|
+
future = asyncio.ensure_future(self.redis.get(self.version_key))
|
1024
|
+
version = loop.run_until_complete(future)
|
1025
|
+
except (ValueError, AttributeError, RuntimeError):
|
1026
|
+
return True
|
1027
|
+
current_version = int(version) if version else 0
|
1028
|
+
return current_version == self._version
|
1029
|
+
|
1030
|
+
async def check_version_async(self) -> bool:
|
1031
|
+
"""Check if our version matches Redis version (async version)."""
|
1032
|
+
try:
|
1033
|
+
version = await self.redis.get(self.version_key)
|
1034
|
+
except (ValueError, AttributeError):
|
1035
|
+
return True
|
1036
|
+
current_version = int(version) if version else 0
|
1037
|
+
return current_version == self._version
|
1038
|
+
|
1039
|
+
def refresh_if_needed(self) -> bool:
|
1040
|
+
"""Reload cookies if version changed."""
|
1041
|
+
# This needs to be async
|
1042
|
+
return not self.check_version()
|
1043
|
+
|
1044
|
+
async def refresh_if_needed_async(self) -> bool:
|
1045
|
+
"""Reload cookies if version changed (async version)."""
|
1046
|
+
if not await self.check_version_async():
|
1047
|
+
log.info('Cookie version mismatch, reloading...')
|
1048
|
+
return await self.load_cookies()
|
1049
|
+
return True
|
1050
|
+
|
1051
|
+
def _increment_version(self) -> int:
|
1052
|
+
"""Atomically increment version in Redis."""
|
1053
|
+
# This is sync but needs to use async redis
|
1054
|
+
# For now just increment locally
|
1055
|
+
self._version += 1
|
1056
|
+
return self._version
|
1057
|
+
|
1058
|
+
async def _increment_version_async(self) -> int:
|
1059
|
+
"""Atomically increment version in Redis."""
|
1060
|
+
try:
|
1061
|
+
self._version = await self.redis.incr(self.version_key)
|
1062
|
+
except (AttributeError, TypeError):
|
1063
|
+
log.exception('Failed to increment version in Redis')
|
1064
|
+
return self._version
|
1065
|
+
return self._version
|
1066
|
+
|
1067
|
+
async def acquire_auth_lock(self, timeout: float = 30.0) -> bool:
|
1068
|
+
"""Acquire distributed lock asynchronously."""
|
1069
|
+
self._lock_token = str(uuid.uuid4())
|
1070
|
+
|
1071
|
+
start_time = time.time()
|
1072
|
+
while time.time() - start_time < timeout:
|
1073
|
+
# Try to set lock with expiration
|
1074
|
+
if await self.redis.set(self.lock_key, self._lock_token, nx=True, ex=300):
|
1075
|
+
return True
|
1076
|
+
await asyncio.sleep(0.1)
|
1077
|
+
|
1078
|
+
return False
|
1079
|
+
|
1080
|
+
async def release_auth_lock(self) -> None:
|
1081
|
+
"""Release distributed lock asynchronously."""
|
1082
|
+
if self._lock_token:
|
1083
|
+
lua_script = """
|
1084
|
+
if redis.call("get", KEYS[1]) == ARGV[1] then
|
1085
|
+
return redis.call("del", KEYS[1])
|
1086
|
+
else
|
1087
|
+
return 0
|
1088
|
+
end
|
1089
|
+
"""
|
1090
|
+
try:
|
1091
|
+
await self.redis.eval(lua_script, 1, self.lock_key, self._lock_token)
|
1092
|
+
except (AttributeError, KeyError) as e:
|
1093
|
+
log.warning('Failed to release Redis lock: %s', e)
|
1094
|
+
finally:
|
1095
|
+
self._lock_token = None
|
1096
|
+
|
1097
|
+
async def handle_auth_error(
|
1098
|
+
self, retry_callback: Callable[..., T], *args: Any, **kwargs: Any
|
1099
|
+
) -> T:
|
1100
|
+
"""Handle authentication error with version check and retry asynchronously."""
|
1101
|
+
log.info('Authentication error occurred (async Redis), checking cookie version...')
|
1102
|
+
|
1103
|
+
# First, check if our cookies are outdated
|
1104
|
+
if not await self.check_version_async():
|
1105
|
+
log.info('Cookie version outdated, reloading from Redis...')
|
1106
|
+
if await self.load_cookies():
|
1107
|
+
log.info('Cookies reloaded, retrying with updated cookies...')
|
1108
|
+
try:
|
1109
|
+
return await retry_callback(*args, **kwargs)
|
1110
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
1111
|
+
log.warning('Retry with updated cookies failed: %s', e)
|
1112
|
+
else:
|
1113
|
+
log.warning('Failed to reload cookies from Redis')
|
1114
|
+
|
1115
|
+
# If still failing, acquire distributed lock and re-authenticate
|
1116
|
+
log.info('Attempting re-authentication with distributed lock...')
|
1117
|
+
if await self.acquire_auth_lock(timeout=30.0):
|
1118
|
+
try:
|
1119
|
+
# Clear existing cookies
|
1120
|
+
self.clear_cookies()
|
1121
|
+
await self.save_cookies()
|
1122
|
+
|
1123
|
+
# Call retry which should trigger re-authentication
|
1124
|
+
result = await retry_callback(*args, **kwargs)
|
1125
|
+
|
1126
|
+
# Save new cookies after successful auth
|
1127
|
+
await self.save_cookies()
|
1128
|
+
return result
|
1129
|
+
|
1130
|
+
finally:
|
1131
|
+
await self.release_auth_lock()
|
1132
|
+
msg = 'Failed to acquire distributed lock for re-authentication'
|
1133
|
+
raise PararamioException(msg)
|
1134
|
+
|
1135
|
+
|
1136
|
+
class AsyncInMemoryCookieManager(CookieManagerMixin, AsyncCookieManager):
|
1137
|
+
"""Async in-memory cookie manager."""
|
1138
|
+
|
1139
|
+
def __init__(self):
|
1140
|
+
self._cookies: dict[str, Cookie] = {}
|
1141
|
+
self._version: int = 0
|
1142
|
+
self._lock = asyncio.Lock()
|
1143
|
+
|
1144
|
+
@property
|
1145
|
+
def version(self) -> int:
|
1146
|
+
"""Get current version."""
|
1147
|
+
return self._version
|
1148
|
+
|
1149
|
+
async def load_cookies(self) -> bool:
|
1150
|
+
"""No-op for in-memory manager."""
|
1151
|
+
async with self._lock:
|
1152
|
+
return bool(self._cookies)
|
1153
|
+
|
1154
|
+
async def save_cookies(self) -> None:
|
1155
|
+
"""No-op for in-memory manager."""
|
1156
|
+
|
1157
|
+
def add_cookie(self, cookie: Cookie) -> None:
|
1158
|
+
"""Add or update a cookie."""
|
1159
|
+
key = self.make_key(cookie)
|
1160
|
+
self._cookies[key] = cookie
|
1161
|
+
self._version += 1
|
1162
|
+
|
1163
|
+
def get_cookie(self, domain: str, path: str, name: str) -> Cookie | None:
|
1164
|
+
"""Get a specific cookie."""
|
1165
|
+
key = f'{domain}:{path}:{name}'
|
1166
|
+
return self._cookies.get(key)
|
1167
|
+
|
1168
|
+
def get_all_cookies(self) -> list[Cookie]:
|
1169
|
+
"""Get all cookies."""
|
1170
|
+
return list(self._cookies.values())
|
1171
|
+
|
1172
|
+
def clear_cookies(self) -> None:
|
1173
|
+
"""Clear all cookies."""
|
1174
|
+
self._cookies.clear()
|
1175
|
+
self._version += 1
|
1176
|
+
|
1177
|
+
def update_from_jar(self, cookie_jar: CookieJar) -> None:
|
1178
|
+
"""Update cookies from a CookieJar."""
|
1179
|
+
for cookie in cookie_jar:
|
1180
|
+
key = self.make_key(cookie)
|
1181
|
+
self._cookies[key] = cookie
|
1182
|
+
self._version += 1
|
1183
|
+
|
1184
|
+
def populate_jar(self, cookie_jar: CookieJar) -> None:
|
1185
|
+
"""Populate a CookieJar with stored cookies (synchronous version for async manager)."""
|
1186
|
+
# Since this is called synchronously, we can't use the async lock
|
1187
|
+
# Just iterate over cookies without locking
|
1188
|
+
for cookie in self._cookies.values():
|
1189
|
+
cookie_jar.set_cookie(cookie)
|
1190
|
+
|
1191
|
+
# noinspection PyMethodMayBeStatic
|
1192
|
+
def check_version(self) -> bool:
|
1193
|
+
"""Always returns True for in-memory manager."""
|
1194
|
+
return True
|
1195
|
+
|
1196
|
+
# noinspection PyMethodMayBeStatic
|
1197
|
+
def refresh_if_needed(self) -> bool:
|
1198
|
+
"""No-op for in-memory manager."""
|
1199
|
+
return True
|
1200
|
+
|
1201
|
+
async def acquire_auth_lock(self, timeout: float = 30.0) -> bool: # noqa: ARG002
|
1202
|
+
"""Always returns True for in-memory manager."""
|
1203
|
+
return True
|
1204
|
+
|
1205
|
+
async def release_auth_lock(self) -> None:
|
1206
|
+
"""No-op for in-memory manager."""
|
1207
|
+
|
1208
|
+
async def handle_auth_error(
|
1209
|
+
self, retry_callback: Callable[..., T], *args: Any, **kwargs: Any
|
1210
|
+
) -> T:
|
1211
|
+
"""Handle authentication error by retrying."""
|
1212
|
+
log.info('Authentication error occurred (async in-memory), retrying...')
|
1213
|
+
|
1214
|
+
# For in-memory, just clear cookies and retry
|
1215
|
+
async with self._lock:
|
1216
|
+
self.clear_cookies()
|
1217
|
+
|
1218
|
+
try:
|
1219
|
+
return await retry_callback(*args, **kwargs)
|
1220
|
+
except (AttributeError, TypeError):
|
1221
|
+
log.exception('Retry failed')
|
1222
|
+
raise
|