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.
Files changed (57) hide show
  1. pararamio_aio/__init__.py +78 -0
  2. pararamio_aio/_core/__init__.py +125 -0
  3. pararamio_aio/_core/_types.py +120 -0
  4. pararamio_aio/_core/base.py +143 -0
  5. pararamio_aio/_core/client_protocol.py +90 -0
  6. pararamio_aio/_core/constants/__init__.py +7 -0
  7. pararamio_aio/_core/constants/base.py +9 -0
  8. pararamio_aio/_core/constants/endpoints.py +84 -0
  9. pararamio_aio/_core/cookie_decorator.py +208 -0
  10. pararamio_aio/_core/cookie_manager.py +1222 -0
  11. pararamio_aio/_core/endpoints.py +67 -0
  12. pararamio_aio/_core/exceptions/__init__.py +6 -0
  13. pararamio_aio/_core/exceptions/auth.py +91 -0
  14. pararamio_aio/_core/exceptions/base.py +124 -0
  15. pararamio_aio/_core/models/__init__.py +17 -0
  16. pararamio_aio/_core/models/base.py +66 -0
  17. pararamio_aio/_core/models/chat.py +92 -0
  18. pararamio_aio/_core/models/post.py +65 -0
  19. pararamio_aio/_core/models/user.py +54 -0
  20. pararamio_aio/_core/py.typed +2 -0
  21. pararamio_aio/_core/utils/__init__.py +73 -0
  22. pararamio_aio/_core/utils/async_requests.py +417 -0
  23. pararamio_aio/_core/utils/auth_flow.py +202 -0
  24. pararamio_aio/_core/utils/authentication.py +235 -0
  25. pararamio_aio/_core/utils/captcha.py +92 -0
  26. pararamio_aio/_core/utils/helpers.py +336 -0
  27. pararamio_aio/_core/utils/http_client.py +199 -0
  28. pararamio_aio/_core/utils/requests.py +424 -0
  29. pararamio_aio/_core/validators.py +78 -0
  30. pararamio_aio/_types.py +29 -0
  31. pararamio_aio/client.py +989 -0
  32. pararamio_aio/constants/__init__.py +16 -0
  33. pararamio_aio/cookie_manager.py +15 -0
  34. pararamio_aio/exceptions/__init__.py +31 -0
  35. pararamio_aio/exceptions/base.py +1 -0
  36. pararamio_aio/file_operations.py +232 -0
  37. pararamio_aio/models/__init__.py +32 -0
  38. pararamio_aio/models/activity.py +127 -0
  39. pararamio_aio/models/attachment.py +141 -0
  40. pararamio_aio/models/base.py +83 -0
  41. pararamio_aio/models/bot.py +274 -0
  42. pararamio_aio/models/chat.py +722 -0
  43. pararamio_aio/models/deferred_post.py +174 -0
  44. pararamio_aio/models/file.py +103 -0
  45. pararamio_aio/models/group.py +361 -0
  46. pararamio_aio/models/poll.py +275 -0
  47. pararamio_aio/models/post.py +643 -0
  48. pararamio_aio/models/team.py +403 -0
  49. pararamio_aio/models/user.py +239 -0
  50. pararamio_aio/py.typed +2 -0
  51. pararamio_aio/utils/__init__.py +18 -0
  52. pararamio_aio/utils/authentication.py +383 -0
  53. pararamio_aio/utils/requests.py +75 -0
  54. pararamio_aio-2.1.1.dist-info/METADATA +269 -0
  55. pararamio_aio-2.1.1.dist-info/RECORD +57 -0
  56. pararamio_aio-2.1.1.dist-info/WHEEL +5 -0
  57. 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