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,235 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import binascii
|
4
|
+
import json
|
5
|
+
import logging
|
6
|
+
import time
|
7
|
+
from typing import TYPE_CHECKING, Any
|
8
|
+
from urllib.error import HTTPError
|
9
|
+
|
10
|
+
from pararamio_aio._core import exceptions as ex
|
11
|
+
from pararamio_aio._core.constants import XSRF_HEADER_NAME
|
12
|
+
from pararamio_aio._core.exceptions import RateLimitException
|
13
|
+
from pararamio_aio._core.utils.http_client import RateLimitHandler
|
14
|
+
|
15
|
+
from .auth_flow import generate_otp
|
16
|
+
from .requests import api_request, raw_api_request
|
17
|
+
|
18
|
+
if TYPE_CHECKING:
|
19
|
+
from http.cookiejar import CookieJar
|
20
|
+
|
21
|
+
from pararamio_aio._core._types import HeaderLikeT, SecondStepFnT
|
22
|
+
|
23
|
+
__all__ = (
|
24
|
+
'authenticate',
|
25
|
+
'do_second_step',
|
26
|
+
'do_second_step_with_code',
|
27
|
+
'get_xsrf_token',
|
28
|
+
)
|
29
|
+
|
30
|
+
XSFR_URL = INIT_URL = '/auth/init'
|
31
|
+
LOGIN_URL = '/auth/login/password'
|
32
|
+
TWO_STEP_URL = '/auth/totp'
|
33
|
+
AUTH_URL = '/auth/next'
|
34
|
+
log = logging.getLogger('pararamio')
|
35
|
+
|
36
|
+
|
37
|
+
def get_xsrf_token(cookie_jar: CookieJar) -> str:
|
38
|
+
_, headers = raw_api_request(XSFR_URL, cookie_jar=cookie_jar)
|
39
|
+
for key, value in headers:
|
40
|
+
if key.lower() == 'x-xsrftoken':
|
41
|
+
return value
|
42
|
+
msg = f'XSFR Header was not found in {XSFR_URL} url'
|
43
|
+
raise ex.PararamioXSFRRequestError(msg)
|
44
|
+
|
45
|
+
|
46
|
+
def do_init(cookie_jar: CookieJar, headers: dict) -> tuple[bool, dict]:
|
47
|
+
try:
|
48
|
+
return True, api_request(
|
49
|
+
INIT_URL,
|
50
|
+
method='GET',
|
51
|
+
headers=headers,
|
52
|
+
cookie_jar=cookie_jar,
|
53
|
+
)
|
54
|
+
except HTTPError as e:
|
55
|
+
if e.code < 500:
|
56
|
+
return False, json.loads(e.read())
|
57
|
+
raise
|
58
|
+
|
59
|
+
|
60
|
+
def do_login(login: str, password: str, cookie_jar: CookieJar, headers: dict) -> tuple[bool, dict]:
|
61
|
+
try:
|
62
|
+
return True, api_request(
|
63
|
+
LOGIN_URL,
|
64
|
+
method='POST',
|
65
|
+
data={'email': login, 'password': password},
|
66
|
+
headers=headers,
|
67
|
+
cookie_jar=cookie_jar,
|
68
|
+
)
|
69
|
+
except HTTPError as e:
|
70
|
+
if e.code < 500:
|
71
|
+
return False, json.loads(e.read())
|
72
|
+
raise
|
73
|
+
|
74
|
+
|
75
|
+
def do_taking_secret(cookie_jar: CookieJar, headers: dict) -> tuple[bool, dict]:
|
76
|
+
try:
|
77
|
+
return True, api_request(
|
78
|
+
AUTH_URL,
|
79
|
+
method='GET',
|
80
|
+
headers=headers,
|
81
|
+
cookie_jar=cookie_jar,
|
82
|
+
)
|
83
|
+
except HTTPError as e:
|
84
|
+
if e.code < 500:
|
85
|
+
return False, json.loads(e.read())
|
86
|
+
raise
|
87
|
+
|
88
|
+
|
89
|
+
def do_second_step(cookie_jar: CookieJar, headers: dict, key: str) -> tuple[bool, dict[str, str]]:
|
90
|
+
"""
|
91
|
+
do second step pararam login with TFA key or raise Exception
|
92
|
+
:param cookie_jar: cookie container
|
93
|
+
:param headers: headers to send
|
94
|
+
:param key: key to generate one time code
|
95
|
+
:return: True if login success
|
96
|
+
"""
|
97
|
+
if not key:
|
98
|
+
msg = 'key can not be empty'
|
99
|
+
raise ex.PararamioSecondFactorAuthenticationException(msg)
|
100
|
+
try:
|
101
|
+
key = generate_otp(key)
|
102
|
+
except binascii.Error as e:
|
103
|
+
msg = 'Invalid second step key'
|
104
|
+
raise ex.PararamioSecondFactorAuthenticationException(msg) from e
|
105
|
+
try:
|
106
|
+
resp = api_request(
|
107
|
+
TWO_STEP_URL,
|
108
|
+
method='POST',
|
109
|
+
data={'code': key},
|
110
|
+
headers=headers,
|
111
|
+
cookie_jar=cookie_jar,
|
112
|
+
)
|
113
|
+
except HTTPError as e:
|
114
|
+
if e.code < 500:
|
115
|
+
return False, json.loads(e.read())
|
116
|
+
raise
|
117
|
+
return True, resp
|
118
|
+
|
119
|
+
|
120
|
+
def do_second_step_with_code(
|
121
|
+
cookie_jar: CookieJar, headers: dict[str, str], code: str
|
122
|
+
) -> tuple[bool, dict[str, str]]:
|
123
|
+
"""
|
124
|
+
do second step pararam login with TFA code or raise Exception
|
125
|
+
:param cookie_jar: cookie container
|
126
|
+
:param headers: headers to send
|
127
|
+
:param code: 6 digits code
|
128
|
+
:return: True if login success
|
129
|
+
"""
|
130
|
+
if not code:
|
131
|
+
msg = 'code can not be empty'
|
132
|
+
raise ex.PararamioSecondFactorAuthenticationException(msg)
|
133
|
+
if len(code) != 6:
|
134
|
+
msg = 'code must be 6 digits len'
|
135
|
+
raise ex.PararamioSecondFactorAuthenticationException(msg)
|
136
|
+
try:
|
137
|
+
resp = api_request(
|
138
|
+
TWO_STEP_URL,
|
139
|
+
method='POST',
|
140
|
+
data={'code': code},
|
141
|
+
headers=headers,
|
142
|
+
cookie_jar=cookie_jar,
|
143
|
+
)
|
144
|
+
except HTTPError as e:
|
145
|
+
if e.code < 500:
|
146
|
+
return False, json.loads(e.read())
|
147
|
+
raise
|
148
|
+
return True, resp
|
149
|
+
|
150
|
+
|
151
|
+
def _handle_rate_limit(wait_auth_limit: bool) -> None:
|
152
|
+
"""Handle rate limiting before authentication."""
|
153
|
+
rate_limit_handler = RateLimitHandler()
|
154
|
+
should_wait, wait_seconds = rate_limit_handler.should_wait()
|
155
|
+
if should_wait:
|
156
|
+
if wait_auth_limit:
|
157
|
+
time.sleep(wait_seconds)
|
158
|
+
else:
|
159
|
+
msg = f'Rate limit exceeded. Retry after {wait_seconds} seconds'
|
160
|
+
raise RateLimitException(msg, retry_after=wait_seconds)
|
161
|
+
|
162
|
+
|
163
|
+
def _handle_captcha(
|
164
|
+
login: str, password: str, cookie_jar: CookieJar, headers: dict[str, Any], resp: dict[str, Any]
|
165
|
+
) -> tuple[bool, tuple[bool, dict[str, Any]]]:
|
166
|
+
"""Handle captcha requirement during authentication.
|
167
|
+
|
168
|
+
Returns:
|
169
|
+
Tuple of (was_captcha_required, (success, response))
|
170
|
+
"""
|
171
|
+
if resp.get('codes', {}).get('non_field', '') != 'captcha_required':
|
172
|
+
return False, (False, resp)
|
173
|
+
|
174
|
+
try:
|
175
|
+
from pararamio_aio._core.utils.captcha import ( # pylint: disable=import-outside-toplevel
|
176
|
+
show_captcha,
|
177
|
+
)
|
178
|
+
|
179
|
+
success = show_captcha(f'login:{login}', headers, cookie_jar)
|
180
|
+
if not success:
|
181
|
+
msg = 'Captcha required'
|
182
|
+
raise ex.PararamioCaptchaAuthenticationException(msg)
|
183
|
+
return True, do_login(login, password, cookie_jar, headers)
|
184
|
+
except ImportError as e:
|
185
|
+
msg = 'Captcha required, but exception when show it'
|
186
|
+
raise ex.PararamioCaptchaAuthenticationException(msg) from e
|
187
|
+
|
188
|
+
|
189
|
+
def authenticate(
|
190
|
+
login: str,
|
191
|
+
password: str,
|
192
|
+
cookie_jar: CookieJar,
|
193
|
+
headers: HeaderLikeT | None = None,
|
194
|
+
second_step_fn: SecondStepFnT | None = do_second_step,
|
195
|
+
second_step_arg: str | None = None,
|
196
|
+
wait_auth_limit: bool = False,
|
197
|
+
) -> tuple[bool, dict[str, Any], str]:
|
198
|
+
# Handle rate limiting
|
199
|
+
_handle_rate_limit(wait_auth_limit)
|
200
|
+
|
201
|
+
if not headers or XSRF_HEADER_NAME not in headers:
|
202
|
+
if headers is None:
|
203
|
+
headers = {}
|
204
|
+
headers[XSRF_HEADER_NAME] = get_xsrf_token(cookie_jar)
|
205
|
+
|
206
|
+
success, resp = do_login(login, password, cookie_jar, headers)
|
207
|
+
|
208
|
+
# Handle captcha if required
|
209
|
+
captcha_handled, captcha_result = _handle_captcha(login, password, cookie_jar, headers, resp)
|
210
|
+
if captcha_handled:
|
211
|
+
success, resp = captcha_result
|
212
|
+
|
213
|
+
if not success and resp.get('error', 'xsrf'):
|
214
|
+
log.debug('invalid xsrf trying to get new one')
|
215
|
+
headers[XSRF_HEADER_NAME] = get_xsrf_token(cookie_jar)
|
216
|
+
success, resp = do_login(login, password, cookie_jar, headers)
|
217
|
+
|
218
|
+
if not success:
|
219
|
+
log.error('authentication failed: %s', resp.get('error', ''))
|
220
|
+
msg = 'Login, password authentication failed'
|
221
|
+
raise ex.PararamioPasswordAuthenticationException(msg)
|
222
|
+
|
223
|
+
if second_step_fn is not None and second_step_arg:
|
224
|
+
success, resp = second_step_fn(cookie_jar, headers, second_step_arg)
|
225
|
+
if not success:
|
226
|
+
msg = 'Second factor authentication failed'
|
227
|
+
raise ex.PararamioSecondFactorAuthenticationException(msg)
|
228
|
+
|
229
|
+
success, resp = do_taking_secret(cookie_jar, headers)
|
230
|
+
if not success:
|
231
|
+
msg = 'Taking secret failed'
|
232
|
+
raise ex.PararamioAuthenticationException(msg)
|
233
|
+
|
234
|
+
success, resp = do_init(cookie_jar, headers)
|
235
|
+
return True, {'user_id': resp.get('user_id')}, headers[XSRF_HEADER_NAME]
|
@@ -0,0 +1,92 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import base64
|
4
|
+
from typing import TYPE_CHECKING
|
5
|
+
from urllib.error import HTTPError
|
6
|
+
from urllib.parse import urlencode
|
7
|
+
|
8
|
+
from ..constants import REQUEST_TIMEOUT as TIMEOUT
|
9
|
+
from .requests import _base_request, api_request
|
10
|
+
|
11
|
+
if TYPE_CHECKING:
|
12
|
+
from http.cookiejar import CookieJar
|
13
|
+
|
14
|
+
__all__ = ['get_captcha_img', 'verify_captcha']
|
15
|
+
|
16
|
+
|
17
|
+
def get_captcha_img(
|
18
|
+
id_: str,
|
19
|
+
headers: dict | None = None,
|
20
|
+
cookie_jar: CookieJar | None = None,
|
21
|
+
timeout: int = TIMEOUT,
|
22
|
+
) -> bytes: # returns tk.PhotoImage
|
23
|
+
"""
|
24
|
+
Fetch and return a captcha image as bytes from the server based on the provided id.
|
25
|
+
The function retries the request a predetermined number of times if met with HTTPError,
|
26
|
+
and encodes the resulting image data in base64 format before returning it.
|
27
|
+
|
28
|
+
Parameters:
|
29
|
+
id_ (str): The unique identifier for the captcha to be retrieved.
|
30
|
+
headers (dict | None, optional): Additional headers to include in the request.
|
31
|
+
cookie_jar (CookieJar | None, optional): Cookie storage for handling session cookies.
|
32
|
+
timeout (int, optional): Duration (in seconds) before the request times out.
|
33
|
+
|
34
|
+
Returns:
|
35
|
+
bytes: The base64-encoded bytes of the captcha image.
|
36
|
+
"""
|
37
|
+
args = urlencode({'id': id_})
|
38
|
+
url = f'/auth/captcha?{args}'
|
39
|
+
tc = 3
|
40
|
+
data = None
|
41
|
+
while True:
|
42
|
+
try:
|
43
|
+
data = _base_request(
|
44
|
+
url, headers=headers, cookie_jar=cookie_jar, timeout=timeout
|
45
|
+
).read()
|
46
|
+
break
|
47
|
+
except HTTPError:
|
48
|
+
if not tc:
|
49
|
+
raise
|
50
|
+
tc -= 1
|
51
|
+
continue
|
52
|
+
return base64.b64encode(data)
|
53
|
+
|
54
|
+
|
55
|
+
def verify_captcha(
|
56
|
+
code: str,
|
57
|
+
headers: dict | None = None,
|
58
|
+
cookie_jar: CookieJar | None = None,
|
59
|
+
timeout: int = TIMEOUT,
|
60
|
+
) -> bool:
|
61
|
+
"""
|
62
|
+
Verify CAPTCHA with the server.
|
63
|
+
|
64
|
+
This function sends a POST request to the '/auth/captcha' endpoint to
|
65
|
+
verify a given CAPTCHA code. It allows passing optional headers, a
|
66
|
+
cookie jar, and a timeout value for the request. The function returns
|
67
|
+
a boolean indicating whether the CAPTCHA verification was successful.
|
68
|
+
|
69
|
+
Parameters:
|
70
|
+
code: str
|
71
|
+
The CAPTCHA code to be verified.
|
72
|
+
headers: dict | None
|
73
|
+
Optional HTTP headers to be included in the request.
|
74
|
+
cookie_jar: CookieJar | None
|
75
|
+
Optional cookie jar to manage cookies during the request.
|
76
|
+
timeout: int
|
77
|
+
The timeout for the request in seconds.
|
78
|
+
|
79
|
+
Returns:
|
80
|
+
bool
|
81
|
+
True if the CAPTCHA verification was successful, False otherwise.
|
82
|
+
"""
|
83
|
+
url = '/auth/captcha'
|
84
|
+
resp = api_request(
|
85
|
+
url,
|
86
|
+
'POST',
|
87
|
+
data={'code': code},
|
88
|
+
headers=headers,
|
89
|
+
cookie_jar=cookie_jar,
|
90
|
+
timeout=timeout,
|
91
|
+
)
|
92
|
+
return str.lower(resp.get('status', '')) == 'ok'
|
@@ -0,0 +1,336 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import hashlib
|
4
|
+
import math
|
5
|
+
import uuid
|
6
|
+
from datetime import datetime, timezone
|
7
|
+
from html import unescape
|
8
|
+
from typing import (
|
9
|
+
TYPE_CHECKING,
|
10
|
+
Any,
|
11
|
+
Callable,
|
12
|
+
TypeVar,
|
13
|
+
cast,
|
14
|
+
)
|
15
|
+
|
16
|
+
from ..constants import DATETIME_FORMAT
|
17
|
+
from ..exceptions import PararamioValidationException, PararamModelNotLoaded
|
18
|
+
|
19
|
+
if TYPE_CHECKING:
|
20
|
+
from collections.abc import Iterable, Sequence
|
21
|
+
from datetime import timedelta
|
22
|
+
|
23
|
+
from pararamio_aio._core._types import FormatterT
|
24
|
+
|
25
|
+
__all__ = (
|
26
|
+
'check_login_opts',
|
27
|
+
'encode_chat_id',
|
28
|
+
'encode_digit',
|
29
|
+
'format_datetime',
|
30
|
+
'format_or_none',
|
31
|
+
'get_empty_vars',
|
32
|
+
'get_formatted_attr_or_load',
|
33
|
+
'get_utc',
|
34
|
+
'join_ids',
|
35
|
+
'lazy_loader',
|
36
|
+
'parse_datetime',
|
37
|
+
'parse_iso_datetime',
|
38
|
+
'unescape_dict',
|
39
|
+
)
|
40
|
+
|
41
|
+
|
42
|
+
def check_login_opts(login: str | None, password: str | None) -> bool:
|
43
|
+
"""
|
44
|
+
Check if both login and password options are provided and not empty.
|
45
|
+
|
46
|
+
Parameters:
|
47
|
+
login (Optional[str]): The login string to check.
|
48
|
+
password (Optional[str]): The password string to check.
|
49
|
+
|
50
|
+
Returns:
|
51
|
+
bool: True if both login and password are provided and not empty, False otherwise.
|
52
|
+
"""
|
53
|
+
return all(map(bool, [login, password]))
|
54
|
+
|
55
|
+
|
56
|
+
def get_empty_vars(**kwargs: Any):
|
57
|
+
"""
|
58
|
+
Identifies and returns a comma-separated string of keys from the keyword
|
59
|
+
arguments where the corresponding values are empty.
|
60
|
+
|
61
|
+
Parameters:
|
62
|
+
**kwargs (Any): Arbitrary keyword arguments with values to be checked.
|
63
|
+
|
64
|
+
Returns:
|
65
|
+
str: A comma-separated string of keys with empty values.
|
66
|
+
"""
|
67
|
+
return ', '.join([k for k, v in kwargs.items() if not v])
|
68
|
+
|
69
|
+
|
70
|
+
def encode_digit(digit: int, res: str = '') -> str:
|
71
|
+
"""
|
72
|
+
Encodes a given integer into a custom base-64-like string.
|
73
|
+
|
74
|
+
Parameters:
|
75
|
+
digit (int): The integer to be encoded.
|
76
|
+
res (str): The resulting encoded string, used internally for recursion.
|
77
|
+
|
78
|
+
Returns:
|
79
|
+
str: The encoded string representation of the given integer.
|
80
|
+
"""
|
81
|
+
if not isinstance(digit, int):
|
82
|
+
digit = int(digit)
|
83
|
+
# noinspection SpellCheckingInspection
|
84
|
+
code_string = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_.'
|
85
|
+
result = math.floor(digit / len(code_string))
|
86
|
+
res = code_string[int(digit % len(code_string))] + res
|
87
|
+
return encode_digit(result, res) if result > 0 else res
|
88
|
+
|
89
|
+
|
90
|
+
def encode_chat_id(chat_id: int, posts_count: int, last_read_post_no: int) -> str:
|
91
|
+
"""
|
92
|
+
Encodes the given chat details into a single string.
|
93
|
+
|
94
|
+
This function takes a chat ID, the count of posts in the chat, and the number
|
95
|
+
of the last read post, and combines them into a single string separated by hyphens.
|
96
|
+
|
97
|
+
Parameters:
|
98
|
+
chat_id (int): The unique identifier for the chat.
|
99
|
+
posts_count (int): The total number of posts in the chat.
|
100
|
+
last_read_post_no (int): The number of the last post read by the user.
|
101
|
+
|
102
|
+
Returns:
|
103
|
+
str: A single string containing the encoded chat details separated by hyphens.
|
104
|
+
"""
|
105
|
+
return '-'.join(map(str, [chat_id, posts_count, last_read_post_no]))
|
106
|
+
|
107
|
+
|
108
|
+
def encode_chats_ids(chats_ids: list[tuple[int, int, int]]) -> str:
|
109
|
+
"""
|
110
|
+
Encodes a list of chat IDs into a single string representation.
|
111
|
+
|
112
|
+
This function takes a list of chat ID tuples and converts each tuple into an encoded string
|
113
|
+
using the encode_chat_id function.
|
114
|
+
The encoded strings are then joined together by a '/' delimiter.
|
115
|
+
|
116
|
+
Parameters:
|
117
|
+
chats_ids (List[Tuple[int, int, int]]): A list of tuples, where each tuple contains
|
118
|
+
three integers representing a chat ID.
|
119
|
+
|
120
|
+
Returns:
|
121
|
+
str: A single string containing all encoded chat IDs joined by '/'.
|
122
|
+
"""
|
123
|
+
return '/'.join(encode_chat_id(*chat_id) for chat_id in chats_ids)
|
124
|
+
|
125
|
+
|
126
|
+
def lazy_loader(
|
127
|
+
cls: Any,
|
128
|
+
items: Sequence,
|
129
|
+
load_fn: Callable[[Any, list], list],
|
130
|
+
per_load: int = 50,
|
131
|
+
) -> Iterable:
|
132
|
+
"""
|
133
|
+
A generator function that loads items lazily in batches from a provided sequence.
|
134
|
+
|
135
|
+
Parameters:
|
136
|
+
cls (Any): The class or instance context used by the load function.
|
137
|
+
items (Sequence): The collection of items to be loaded.
|
138
|
+
load_fn (Callable[[Any, List], List]): The function responsible for loading a batch of items.
|
139
|
+
It must accept the class or instance (cls) and a subset of items,
|
140
|
+
and return a list of loaded items.
|
141
|
+
per_load (int): The number of items to load in each batch. Default is 50.
|
142
|
+
|
143
|
+
Returns:
|
144
|
+
Iterable: An iterator yielding items, loaded in batches.
|
145
|
+
"""
|
146
|
+
load_counter = 0
|
147
|
+
loaded_items: list[Any] = []
|
148
|
+
counter = 0
|
149
|
+
|
150
|
+
def load_items():
|
151
|
+
return load_fn(cls, items[(per_load * load_counter) : (per_load * load_counter) + per_load])
|
152
|
+
|
153
|
+
for _ in items:
|
154
|
+
if not loaded_items:
|
155
|
+
loaded_items = load_items()
|
156
|
+
if counter >= per_load:
|
157
|
+
counter = 0
|
158
|
+
load_counter += 1
|
159
|
+
loaded_items = load_items()
|
160
|
+
yield loaded_items[counter]
|
161
|
+
counter += 1
|
162
|
+
|
163
|
+
|
164
|
+
def join_ids(items: Sequence[Any]) -> str:
|
165
|
+
"""
|
166
|
+
Converts a list of items into a single comma-separated string.
|
167
|
+
|
168
|
+
Args:
|
169
|
+
items (List[Any]): A list containing elements of any type to be joined.
|
170
|
+
|
171
|
+
Returns:
|
172
|
+
str: A comma-separated string representation of the elements in the list.
|
173
|
+
"""
|
174
|
+
return ','.join(map(str, items))
|
175
|
+
|
176
|
+
|
177
|
+
def get_utc(date: datetime) -> datetime:
|
178
|
+
"""
|
179
|
+
Converts an offset-aware datetime object to its UTC equivalent.
|
180
|
+
|
181
|
+
Parameters:
|
182
|
+
date (datetime): The"""
|
183
|
+
if date.tzinfo is None:
|
184
|
+
msg = 'is not offset-aware datetime'
|
185
|
+
raise PararamioValidationException(msg)
|
186
|
+
return cast('datetime', date - cast('timedelta', date.utcoffset()))
|
187
|
+
|
188
|
+
|
189
|
+
def parse_datetime(
|
190
|
+
data: dict[str, Any],
|
191
|
+
key: str,
|
192
|
+
format_: str = DATETIME_FORMAT,
|
193
|
+
) -> datetime | None:
|
194
|
+
"""
|
195
|
+
Parse a datetime object from a dictionary.
|
196
|
+
|
197
|
+
Parameters:
|
198
|
+
data (Dict[str, Any]): The dictionary containing the datetime string to parse.
|
199
|
+
key (str): The key in the dictionary where the datetime string is stored.
|
200
|
+
format_ (str): The format in which the datetime string is stored.
|
201
|
+
|
202
|
+
Returns:
|
203
|
+
Optional[datetime]: The parsed datetime object with UTC timezone, or None
|
204
|
+
if the key is not found.
|
205
|
+
"""
|
206
|
+
if key not in data:
|
207
|
+
return None
|
208
|
+
return datetime.strptime(data[key], format_).replace(tzinfo=timezone.utc)
|
209
|
+
|
210
|
+
|
211
|
+
def parse_iso_datetime(data: dict[str, Any], key: str) -> datetime | None:
|
212
|
+
"""
|
213
|
+
Parses an ISO 8601 formatted datetime string from a dictionary by a given key.
|
214
|
+
|
215
|
+
Parameters:
|
216
|
+
data (Dict[str, Any]): The dictionary containing the datetime string.
|
217
|
+
key (str): The key used to extract the datetime string from the dictionary.
|
218
|
+
|
219
|
+
Returns:
|
220
|
+
Optional[datetime]: A datetime object if parsing is successful, otherwise None.
|
221
|
+
"""
|
222
|
+
try:
|
223
|
+
return parse_datetime(data, key, '%Y-%m-%dT%H:%M:%S.%fZ')
|
224
|
+
except ValueError:
|
225
|
+
return parse_datetime(data, key, '%Y-%m-%dT%H:%M:%SZ')
|
226
|
+
|
227
|
+
|
228
|
+
def format_datetime(date: datetime) -> str:
|
229
|
+
"""
|
230
|
+
Formats the given datetime object to a string in UTC using a predefined format.
|
231
|
+
|
232
|
+
Arguments:
|
233
|
+
date (datetime): The datetime object to format.
|
234
|
+
|
235
|
+
Returns:
|
236
|
+
str: The formatted datetime string.
|
237
|
+
"""
|
238
|
+
return get_utc(date).strftime(DATETIME_FORMAT)
|
239
|
+
|
240
|
+
|
241
|
+
def rand_id():
|
242
|
+
"""
|
243
|
+
|
244
|
+
Generates a pseudo-random identifier.
|
245
|
+
|
246
|
+
This function generates a pseudo-random identifier by using the UUID
|
247
|
+
and MD5 hashing algorithm. The UUID is first converted to its hexadecimal
|
248
|
+
representation and encoded into bytes.
|
249
|
+
An MD5 hash is then computed from these bytes.
|
250
|
+
The
|
251
|
+
resulting hash is converted to an integer,
|
252
|
+
scaled by a factor of 10^-21, and finally returned as a string.
|
253
|
+
|
254
|
+
Returns:
|
255
|
+
str: The generated pseudo-random identifier
|
256
|
+
"""
|
257
|
+
_hash = hashlib.md5(bytes(uuid.uuid4().hex, 'utf8'))
|
258
|
+
return str(int(int(_hash.hexdigest(), 16) * 10**-21))
|
259
|
+
|
260
|
+
|
261
|
+
T = TypeVar('T', bound=dict)
|
262
|
+
|
263
|
+
|
264
|
+
def unescape_dict(d: T, keys: list[str]) -> T:
|
265
|
+
"""
|
266
|
+
Unescapes the values of specified keys in a dictionary.
|
267
|
+
|
268
|
+
This function takes a dictionary and a list of keys,
|
269
|
+
and returns a new dictionary where the values of the specified keys have been unescaped.
|
270
|
+
All other keys and values remain unchanged.
|
271
|
+
|
272
|
+
Args:
|
273
|
+
d (T): The dictionary whose values are to be unescaped.
|
274
|
+
keys (List[str]): A list of keys for which the values should be unescaped.
|
275
|
+
|
276
|
+
Returns:
|
277
|
+
T: A new dictionary with the values of specified keys unescaped.
|
278
|
+
"""
|
279
|
+
return cast('T', {k: unescape(v) if k in keys else v for k, v in d.items()})
|
280
|
+
|
281
|
+
|
282
|
+
def format_or_none(key: str, data: dict[str, Any], formatter: FormatterT | None) -> Any:
|
283
|
+
"""
|
284
|
+
Formats the value associated with the given key if a formatter is provided;
|
285
|
+
otherwise, returns the unformatted value.
|
286
|
+
|
287
|
+
Parameters:
|
288
|
+
key (str): The key for which the value should be retrieved and optionally formatted.
|
289
|
+
data (Dict[str, Any]): The dictionary containing the data.
|
290
|
+
formatter (Optional[FormatterT]): An optional formatter dictionary where keys are the same as
|
291
|
+
in data and values are formatting functions.
|
292
|
+
|
293
|
+
Returns:
|
294
|
+
Any: The formatted value associated with the key if a formatter exists for it
|
295
|
+
otherwise, the unformatted value.
|
296
|
+
"""
|
297
|
+
if formatter is not None and key in formatter:
|
298
|
+
return formatter[key](data, key)
|
299
|
+
return data[key]
|
300
|
+
|
301
|
+
|
302
|
+
def get_formatted_attr_or_load(
|
303
|
+
obj: object,
|
304
|
+
key: str,
|
305
|
+
formatter: FormatterT | None = None,
|
306
|
+
load_fn: Callable[[], Any] | None = None,
|
307
|
+
) -> Any:
|
308
|
+
"""
|
309
|
+
Fetches a formatted attribute from an object's `_data` attribute if it exists,
|
310
|
+
using an optional formatter function. If the attribute does not exist and a
|
311
|
+
`load_fn` function is provided, the function will be called to load the data,
|
312
|
+
and the attribute will be fetched again.
|
313
|
+
|
314
|
+
Args:
|
315
|
+
obj (object): The object containing the `_data` attribute.
|
316
|
+
key (str): The key to look up in the `_data` attribute.
|
317
|
+
formatter (Optional[FormatterT], optional): An optional formatter function to
|
318
|
+
format the retrieved attribute. Defaults to None.
|
319
|
+
load_fn (Optional[Callable[[], Any]], optional): An optional function to call
|
320
|
+
if the key does not exist in the `_data` attribute. Defaults to None.
|
321
|
+
|
322
|
+
Returns:
|
323
|
+
Any: The formatted attribute if found and formatted, else raises PararamModelNotLoaded.
|
324
|
+
|
325
|
+
Raises:
|
326
|
+
PararamModelNotLoaded: If the key does not exist in the `_data` attribute and no
|
327
|
+
`load_fn` is provided.
|
328
|
+
"""
|
329
|
+
try:
|
330
|
+
return format_or_none(key, getattr(obj, '_data', {}), formatter)
|
331
|
+
except KeyError as e:
|
332
|
+
if load_fn is not None:
|
333
|
+
load_fn()
|
334
|
+
return format_or_none(key, getattr(obj, '_data', {}), formatter)
|
335
|
+
msg = f"Attribute '{key}' has not been loaded yet"
|
336
|
+
raise PararamModelNotLoaded(msg) from e
|