pararamio-aio 3.0.0__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 (56) hide show
  1. pararamio_aio/__init__.py +26 -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/exceptions/__init__.py +31 -0
  34. pararamio_aio/exceptions/base.py +1 -0
  35. pararamio_aio/file_operations.py +232 -0
  36. pararamio_aio/models/__init__.py +31 -0
  37. pararamio_aio/models/activity.py +127 -0
  38. pararamio_aio/models/attachment.py +141 -0
  39. pararamio_aio/models/base.py +83 -0
  40. pararamio_aio/models/bot.py +274 -0
  41. pararamio_aio/models/chat.py +722 -0
  42. pararamio_aio/models/deferred_post.py +174 -0
  43. pararamio_aio/models/file.py +103 -0
  44. pararamio_aio/models/group.py +361 -0
  45. pararamio_aio/models/poll.py +275 -0
  46. pararamio_aio/models/post.py +643 -0
  47. pararamio_aio/models/team.py +403 -0
  48. pararamio_aio/models/user.py +239 -0
  49. pararamio_aio/py.typed +2 -0
  50. pararamio_aio/utils/__init__.py +18 -0
  51. pararamio_aio/utils/authentication.py +383 -0
  52. pararamio_aio/utils/requests.py +75 -0
  53. pararamio_aio-3.0.0.dist-info/METADATA +269 -0
  54. pararamio_aio-3.0.0.dist-info/RECORD +56 -0
  55. pararamio_aio-3.0.0.dist-info/WHEEL +5 -0
  56. pararamio_aio-3.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,199 @@
1
+ """Common HTTP client utilities for sync and async implementations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import Any, ClassVar
7
+ from urllib.parse import urljoin
8
+
9
+ from ..constants import BASE_API_URL, VERSION, XSRF_HEADER_NAME
10
+
11
+ __all__ = (
12
+ 'HTTPClientConfig',
13
+ 'RateLimitHandler',
14
+ 'build_url',
15
+ 'prepare_headers',
16
+ 'should_retry_request',
17
+ )
18
+
19
+
20
+ class HTTPClientConfig:
21
+ """Common HTTP client configuration."""
22
+
23
+ DEFAULT_HEADERS: ClassVar[dict[str, str]] = {
24
+ 'Content-type': 'application/json',
25
+ 'Accept': 'application/json',
26
+ 'User-agent': f'pararamio lib version {VERSION}',
27
+ }
28
+
29
+ # Retry configuration
30
+ MAX_RETRIES = 3
31
+ RETRY_DELAY = 1.0 # seconds
32
+ RETRY_BACKOFF = 2.0 # exponential backoff multiplier
33
+
34
+ # Rate limit configuration
35
+ RATE_LIMIT_RETRY_AFTER_DEFAULT = 60 # seconds
36
+ RATE_LIMIT_MAX_RETRIES = 3
37
+
38
+ # Status codes that trigger retry
39
+ RETRY_STATUS_CODES: ClassVar[set[int]] = {429, 502, 503, 504}
40
+
41
+ # Status codes that are considered successful
42
+ SUCCESS_STATUS_CODES: ClassVar[set[int]] = {200, 201, 204}
43
+
44
+
45
+ class RateLimitHandler:
46
+ """Handle rate limiting (429) responses."""
47
+
48
+ def __init__(self):
49
+ self.last_rate_limit_time: float | None = None
50
+ self.retry_after: int | None = None
51
+
52
+ def handle_rate_limit(self, headers: dict[str, str]) -> int:
53
+ """Handle rate limit response and return retry delay.
54
+
55
+ Args:
56
+ headers: Response headers
57
+
58
+ Returns:
59
+ Seconds to wait before retry
60
+ """
61
+ self.last_rate_limit_time = time.time()
62
+
63
+ # Check for Retry-After header
64
+ retry_after = headers.get('Retry-After')
65
+ if retry_after:
66
+ try:
67
+ # Try to parse as integer (seconds)
68
+ self.retry_after = int(retry_after)
69
+ except ValueError:
70
+ # Might be a date, use default
71
+ self.retry_after = HTTPClientConfig.RATE_LIMIT_RETRY_AFTER_DEFAULT
72
+ else:
73
+ self.retry_after = HTTPClientConfig.RATE_LIMIT_RETRY_AFTER_DEFAULT
74
+
75
+ return self.retry_after
76
+
77
+ def should_wait(self) -> tuple[bool, int]:
78
+ """Check if we should wait due to rate limiting.
79
+
80
+ Returns:
81
+ Tuple of (should_wait, seconds_to_wait)
82
+ """
83
+ if self.last_rate_limit_time and self.retry_after:
84
+ elapsed = time.time() - self.last_rate_limit_time
85
+ remaining = self.retry_after - elapsed
86
+
87
+ if remaining > 0:
88
+ return True, int(remaining)
89
+
90
+ return False, 0
91
+
92
+ def clear(self):
93
+ """Clear rate limit state."""
94
+ self.last_rate_limit_time = None
95
+ self.retry_after = None
96
+
97
+
98
+ def prepare_headers(
99
+ custom_headers: dict[str, str] | None = None,
100
+ xsrf_token: str | None = None,
101
+ ) -> dict[str, str]:
102
+ """Prepare request headers.
103
+
104
+ Args:
105
+ custom_headers: Custom headers to add
106
+ xsrf_token: XSRF token to include
107
+
108
+ Returns:
109
+ Combined headers dict
110
+ """
111
+ headers = HTTPClientConfig.DEFAULT_HEADERS.copy()
112
+
113
+ if xsrf_token:
114
+ headers[XSRF_HEADER_NAME] = xsrf_token
115
+
116
+ if custom_headers:
117
+ headers.update(custom_headers)
118
+
119
+ return headers
120
+
121
+
122
+ def build_url(endpoint: str, base_url: str | None = None) -> str:
123
+ """Build full URL from endpoint.
124
+
125
+ Args:
126
+ endpoint: API endpoint path
127
+ base_url: Base URL (defaults to BASE_API_URL)
128
+
129
+ Returns:
130
+ Full URL
131
+ """
132
+ if endpoint.startswith('http'):
133
+ return endpoint
134
+
135
+ base = base_url or BASE_API_URL
136
+ return urljoin(base, endpoint)
137
+
138
+
139
+ def should_retry_request(
140
+ status_code: int,
141
+ attempt: int,
142
+ error: Exception | None = None,
143
+ ) -> tuple[bool, float]:
144
+ """Determine if request should be retried.
145
+
146
+ Args:
147
+ status_code: HTTP status code
148
+ attempt: Current attempt number (1-based)
149
+ error: Optional exception that occurred
150
+
151
+ Returns:
152
+ Tuple of (should_retry, delay_seconds)
153
+ """
154
+ if attempt >= HTTPClientConfig.MAX_RETRIES:
155
+ return False, 0
156
+
157
+ # Check if status code is retryable
158
+ if status_code in HTTPClientConfig.RETRY_STATUS_CODES:
159
+ # Special handling for rate limit
160
+ if status_code == 429:
161
+ # Rate limit delay is handled separately
162
+ return True, 0
163
+
164
+ # Calculate exponential backoff
165
+ delay = HTTPClientConfig.RETRY_DELAY * (HTTPClientConfig.RETRY_BACKOFF ** (attempt - 1))
166
+ return True, delay
167
+
168
+ # Check for network errors
169
+ if error and isinstance(error, (ConnectionError, TimeoutError)):
170
+ delay = HTTPClientConfig.RETRY_DELAY * (HTTPClientConfig.RETRY_BACKOFF ** (attempt - 1))
171
+ return True, delay
172
+
173
+ return False, 0
174
+
175
+
176
+ class RequestResult:
177
+ """Result of an HTTP request."""
178
+
179
+ def __init__(
180
+ self,
181
+ success: bool,
182
+ status_code: int,
183
+ data: dict[str, Any] | None = None,
184
+ headers: dict[str, str] | None = None,
185
+ error: str | None = None,
186
+ ):
187
+ self.success = success
188
+ self.status_code = status_code
189
+ self.data = data or {}
190
+ self.headers = headers or {}
191
+ self.error = error
192
+
193
+ def is_rate_limited(self) -> bool:
194
+ """Check if response indicates rate limiting."""
195
+ return self.status_code == 429
196
+
197
+ def is_success(self) -> bool:
198
+ """Check if response is successful."""
199
+ return self.status_code in HTTPClientConfig.SUCCESS_STATUS_CODES
@@ -0,0 +1,424 @@
1
+ from __future__ import annotations
2
+
3
+ import codecs
4
+ import json
5
+ import logging
6
+ import mimetypes
7
+ from io import BytesIO
8
+ from json import JSONDecodeError
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING, BinaryIO
11
+ from urllib.error import HTTPError
12
+ from urllib.parse import quote
13
+ from urllib.request import HTTPCookieProcessor, Request, build_opener
14
+
15
+ from pararamio_aio._core.constants import BASE_API_URL, FILE_UPLOAD_URL, UPLOAD_TIMEOUT, VERSION
16
+ from pararamio_aio._core.constants import REQUEST_TIMEOUT as TIMEOUT
17
+ from pararamio_aio._core.exceptions import PararamioHTTPRequestException
18
+
19
+ if TYPE_CHECKING:
20
+ from http.client import HTTPResponse
21
+
22
+ from pararamio_aio._core._types import CookieJarT, HeaderLikeT
23
+
24
+ __all__ = (
25
+ 'api_request',
26
+ 'bot_request',
27
+ 'delete_file',
28
+ 'download_file',
29
+ 'raw_api_request',
30
+ 'upload_file',
31
+ 'xupload_file',
32
+ )
33
+ log = logging.getLogger('pararamio')
34
+ UA_HEADER = f'pararamio lib version {VERSION}'
35
+ DEFAULT_HEADERS = {
36
+ 'Content-type': 'application/json',
37
+ 'Accept': 'application/json',
38
+ 'User-agent': UA_HEADER,
39
+ }
40
+ writer = codecs.lookup('utf-8')[3]
41
+
42
+
43
+ def multipart_encode(
44
+ fd: BinaryIO,
45
+ fields: list[tuple[str, str | None | int]] | None = None,
46
+ boundary: str | None = None,
47
+ form_field_name: str = 'data',
48
+ filename: str | None = None,
49
+ content_type: str | None = None,
50
+ ) -> bytes:
51
+ """
52
+ Encodes a file and additional fields into a multipart/form-data payload.
53
+
54
+ Args:
55
+ fd: A file-like object opened in binary mode that is to be included in the payload.
56
+ fields: An optional list of tuples representing additional form fields,
57
+ with each tuple containing a field name and its value.
58
+ boundary: An optional string used to separate parts of the multipart message.
59
+ If not provided, a default boundary ('FORM-BOUNDARY') is used.
60
+ form_field_name: The name of the form field for the file being uploaded. Defaults to 'data'.
61
+ filename: An optional string representing the filename for the file being uploaded.
62
+ If not provided, the name is derived from the file-like object.
63
+ content_type: An optional string representing the content type of the file being uploaded.
64
+ If not provided, the content type will be guessed from the filename.
65
+
66
+ Returns:
67
+ A bytes' object representing the encoded multipart/form-data payload.
68
+ """
69
+ if fields is None:
70
+ fields = []
71
+ if boundary is None:
72
+ boundary = 'FORM-BOUNDARY'
73
+ body = BytesIO()
74
+
75
+ def write(text: str):
76
+ nonlocal body
77
+ writer(body).write(text)
78
+
79
+ if fields:
80
+ for key, value in fields:
81
+ if value is None:
82
+ continue
83
+ write(f'--{boundary}\r\n')
84
+ write(f'Content-Disposition: form-data; name="{key}"')
85
+ write(f'\r\n\r\n{value}\r\n')
86
+ if not filename:
87
+ filename = Path(fd.name).name
88
+ if not content_type:
89
+ content_type = mimetypes.guess_type(filename)[0] or 'application/octet-stream'
90
+ fd.seek(0)
91
+ write(f'--{boundary}\r\n')
92
+ write(f'Content-Disposition: form-data; name="{form_field_name}"; filename="{filename}"\r\n')
93
+ write(f'Content-Type: {content_type}\r\n\r\n')
94
+ body.write(fd.read())
95
+ write(f'\r\n--{boundary}--\r\n\r\n')
96
+ return body.getvalue()
97
+
98
+
99
+ def bot_request(
100
+ url: str,
101
+ key: str,
102
+ method: str = 'GET',
103
+ data: dict | None = None,
104
+ headers: dict | None = None,
105
+ timeout: int = TIMEOUT,
106
+ ):
107
+ """
108
+
109
+ Sends a request to a bot API endpoint with the specified parameters.
110
+
111
+ Parameters:
112
+ url (str): The endpoint URL of the bot API.
113
+ key (str): The API token for authentication.
114
+ method (str): The HTTP method to use for the request. Defaults to 'GET'.
115
+ data (Optional[dict]): The data payload for the request. Defaults to None.
116
+ headers (Optional[dict]): Additional headers to include in the request. Defaults to None.
117
+ timeout (int): The timeout setting for the request. Defaults to TIMEOUT.
118
+
119
+ Returns:
120
+ Response object from the API request.
121
+ """
122
+ _headers = {'X-APIToken': key, **DEFAULT_HEADERS}
123
+ if headers:
124
+ _headers = {**_headers, **headers}
125
+ return api_request(url=url, method=method, data=data, headers=_headers, timeout=timeout)
126
+
127
+
128
+ def _base_request(
129
+ url: str,
130
+ method: str = 'GET',
131
+ data: bytes | None = None,
132
+ headers: dict | None = None,
133
+ cookie_jar: CookieJarT | None = None,
134
+ timeout: int = TIMEOUT,
135
+ ) -> HTTPResponse:
136
+ """
137
+ Sends an HTTP request and returns an HTTPResponse object.
138
+
139
+ Parameters:
140
+ url (str): The URL endpoint to which the request is sent.
141
+ method (str): The HTTP method to use for the request (default is 'GET').
142
+ data (Optional[bytes]): The payload to include in the request body (default is None).
143
+ headers (Optional[dict]): A dictionary of headers to include in the request
144
+ (default is None).
145
+ cookie_jar (Optional[CookieJarT]): A cookie jar to manage cookies for the request
146
+ (default is None).
147
+ timeout (int): The timeout for the request in seconds (TIMEOUT defines default).
148
+
149
+ Returns:
150
+ HTTPResponse: The response object containing the server's response to the HTTP request.
151
+
152
+ Raises:
153
+ PararamioHTTPRequestException: An exception is raised if there is
154
+ an issue with the HTTP request.
155
+ """
156
+ _url = f'{BASE_API_URL}{url}'
157
+ _headers = DEFAULT_HEADERS
158
+ if headers:
159
+ _headers = {**_headers, **headers}
160
+ opener = build_opener(*[HTTPCookieProcessor(cookie_jar)] if cookie_jar is not None else [])
161
+ _data = None
162
+ if data:
163
+ _data = data
164
+ rq = Request(_url, _data, method=method, headers=_headers)
165
+ log.debug('%s - %s - %s - %s - %s', _url, method, data, headers, cookie_jar)
166
+ try:
167
+ return opener.open(rq, timeout=timeout)
168
+ except HTTPError as e:
169
+ log.exception('%s - %s', _url, method)
170
+ # noinspection PyUnresolvedReferences
171
+ raise PararamioHTTPRequestException(e.filename, e.code, e.msg, e.hdrs, e.fp) from e
172
+
173
+
174
+ def _base_file_request(
175
+ url: str,
176
+ method='GET',
177
+ data: bytes | None = None,
178
+ headers: HeaderLikeT | None = None,
179
+ cookie_jar: CookieJarT | None = None, # type: ignore
180
+ timeout: int = TIMEOUT,
181
+ ) -> BytesIO:
182
+ """
183
+ Performs a file request to the specified URL with the given parameters.
184
+
185
+ Arguments:
186
+ url (str): The URL endpoint to send the request to.
187
+ method (str, optional): The HTTP method to use for the request (default is 'GET').
188
+ data (Optional[bytes], optional): The data to send in the request body (default is None).
189
+ headers (Optional[HeaderLikeT], optional): The headers to include in the request
190
+ (default is None).
191
+ cookie_jar (Optional[CookieJarT], optional): The cookie jar to use for managing cookies
192
+ (default is None).
193
+ timeout (int, optional): The timeout duration for the request (default value is TIMEOUT).
194
+
195
+ Returns:
196
+ BytesIO: The response object from the file request.
197
+
198
+ Raises:
199
+ PararamioHTTPRequestException: If the request fails with an HTTP error code.
200
+ """
201
+ _url = f'{FILE_UPLOAD_URL}{url}'
202
+ opener = build_opener(HTTPCookieProcessor(cookie_jar))
203
+ if not headers:
204
+ headers = {}
205
+ rq = Request(_url, data, method=method, headers=headers)
206
+ log.debug('%s - %s - %s - %s - %s', url, method, data, headers, cookie_jar)
207
+ try:
208
+ resp = opener.open(rq, timeout=timeout)
209
+ if 200 >= resp.getcode() < 300:
210
+ return resp
211
+ raise PararamioHTTPRequestException(
212
+ _url, resp.getcode(), 'Unknown error', resp.getheaders(), resp.fp
213
+ )
214
+ except HTTPError as e:
215
+ log.exception('%s - %s', _url, method)
216
+ # noinspection PyUnresolvedReferences
217
+ raise PararamioHTTPRequestException(e.filename, e.code, e.msg, e.hdrs, e.fp) from e
218
+
219
+
220
+ def upload_file(
221
+ fp: BinaryIO,
222
+ perm: str,
223
+ filename: str | None = None,
224
+ file_type=None,
225
+ headers: HeaderLikeT | None = None,
226
+ cookie_jar: CookieJarT | None = None,
227
+ timeout: int = UPLOAD_TIMEOUT,
228
+ ):
229
+ """
230
+ Upload a file to a pararam server with specified permissions and optional parameters.
231
+
232
+ Args:
233
+ fp (BinaryIO): A file-like object to be uploaded.
234
+ perm (str): The permission level for the uploaded file.
235
+ filename (Optional[str], optional): Optional filename used during upload. Defaults to None.
236
+ file_type (optional): Optional MIME type of the file. Defaults to None.
237
+ headers (Optional[HeaderLikeT], optional): Optional headers to include in the request.
238
+ Defaults to None.
239
+ cookie_jar (Optional[CookieJarT], optional): Optional cookie jar for maintaining session.
240
+ Defaults to None.
241
+ timeout (int, optional): Timeout duration for the upload request.
242
+ Defaults to UPLOAD_TIMEOUT.
243
+
244
+ Returns:
245
+ dict: A dictionary containing the server's response to the file upload.
246
+
247
+ The function constructs a multipart form data request with the file contents,
248
+ sends the POST request to the server,
249
+ and returns the parsed JSON response from the server.
250
+ """
251
+ url = f'/upload/{perm}'
252
+ boundary = 'FORM-BOUNDARY'
253
+ _headers = {
254
+ 'User-agent': UA_HEADER,
255
+ **(headers or {}),
256
+ 'Accept': 'application/json',
257
+ 'Content-Type': f'multipart/form-data; boundary={boundary}',
258
+ }
259
+ data = multipart_encode(
260
+ fp,
261
+ boundary=boundary,
262
+ form_field_name='file',
263
+ filename=filename,
264
+ content_type=file_type,
265
+ )
266
+ resp = _base_file_request(
267
+ url,
268
+ method='POST',
269
+ data=data,
270
+ headers=_headers,
271
+ cookie_jar=cookie_jar,
272
+ timeout=timeout,
273
+ )
274
+ return json.loads(resp.read())
275
+
276
+
277
+ def xupload_file(
278
+ fp: BinaryIO,
279
+ fields: list[tuple[str, str | None | int]],
280
+ filename: str | None = None,
281
+ content_type: str | None = None,
282
+ headers: HeaderLikeT | None = None,
283
+ cookie_jar: CookieJarT | None = None,
284
+ timeout: int = UPLOAD_TIMEOUT,
285
+ ) -> dict:
286
+ """
287
+
288
+ Uploads a file to a predefined URL using a multipart/form-data request.
289
+
290
+ Arguments:
291
+ - fp: A binary file-like object to upload.
292
+ - fields: A list of tuples where each tuple contains a field name, z
293
+ and a value which can be
294
+ a string, None, or an integer.
295
+ - filename: Optional; The name of the file being uploaded.
296
+ If not provided, it defaults to None.
297
+ - content_type: Optional; The MIME type of the file being uploaded.
298
+ If not provided, it defaults to None.
299
+ - headers: Optional; Additional headers to include in the upload request.
300
+ If not provided, defaults to None.
301
+ - cookie_jar: Optional; A collection of cookies to include in the upload request.
302
+ If not provided, defaults to None.
303
+ - timeout: Optional; The timeout in seconds for the request.
304
+ Defaults to UPLOAD_TIMEOUT.
305
+
306
+ Returns:
307
+ - A dictionary parsed from the JSON response of the upload request.
308
+
309
+ """
310
+ url = '/upload'
311
+ boundary = 'FORM-BOUNDARY'
312
+ _headers = {
313
+ 'User-agent': UA_HEADER,
314
+ **(headers or {}),
315
+ 'Accept': 'application/json',
316
+ 'Content-Type': f'multipart/form-data; boundary={boundary}',
317
+ }
318
+ data = multipart_encode(
319
+ fp,
320
+ fields,
321
+ filename=filename,
322
+ content_type=content_type,
323
+ boundary=boundary,
324
+ )
325
+ resp = _base_file_request(
326
+ url,
327
+ method='POST',
328
+ data=data,
329
+ headers=_headers,
330
+ cookie_jar=cookie_jar,
331
+ timeout=timeout,
332
+ )
333
+ return json.loads(resp.read())
334
+
335
+
336
+ def delete_file(
337
+ guid: str,
338
+ headers: HeaderLikeT | None = None,
339
+ cookie_jar: CookieJarT | None = None,
340
+ timeout: int = TIMEOUT,
341
+ ) -> dict:
342
+ url = f'/delete/{guid}'
343
+ resp = _base_file_request(
344
+ url, method='DELETE', headers=headers, cookie_jar=cookie_jar, timeout=timeout
345
+ )
346
+ return json.loads(resp.read())
347
+
348
+
349
+ def download_file(
350
+ guid: str,
351
+ filename: str,
352
+ headers: HeaderLikeT | None = None,
353
+ cookie_jar: CookieJarT | None = None,
354
+ timeout: int = TIMEOUT,
355
+ ) -> BytesIO:
356
+ url = f'/download/{guid}/{quote(filename)}'
357
+ return file_request(url, method='GET', headers=headers, cookie_jar=cookie_jar, timeout=timeout)
358
+
359
+
360
+ def file_request(
361
+ url: str,
362
+ method='GET',
363
+ data: bytes | None = None,
364
+ headers: HeaderLikeT | None = None,
365
+ cookie_jar: CookieJarT | None = None,
366
+ timeout: int = TIMEOUT,
367
+ ) -> BytesIO:
368
+ _headers = DEFAULT_HEADERS
369
+ if headers:
370
+ _headers = {**_headers, **headers}
371
+ return _base_file_request(
372
+ url,
373
+ method=method,
374
+ data=data,
375
+ headers=_headers,
376
+ cookie_jar=cookie_jar,
377
+ timeout=timeout,
378
+ )
379
+
380
+
381
+ def raw_api_request(
382
+ url: str,
383
+ method: str = 'GET',
384
+ data: bytes | None = None,
385
+ headers: HeaderLikeT | None = None,
386
+ cookie_jar: CookieJarT | None = None,
387
+ timeout: int = TIMEOUT,
388
+ ) -> tuple[dict, list]:
389
+ resp = _base_request(url, method, data, headers, cookie_jar, timeout)
390
+ if 200 >= resp.getcode() < 300:
391
+ contents = resp.read()
392
+ return json.loads(contents), resp.getheaders()
393
+ return {}, []
394
+
395
+
396
+ def api_request(
397
+ url: str,
398
+ method: str = 'GET',
399
+ data: dict | None = None,
400
+ headers: HeaderLikeT | None = None,
401
+ cookie_jar: CookieJarT | None = None,
402
+ timeout: int = TIMEOUT,
403
+ ) -> dict:
404
+ _data = None
405
+ if data is not None:
406
+ _data = str.encode(json.dumps(data), 'utf-8')
407
+ resp = _base_request(url, method, _data, headers, cookie_jar, timeout)
408
+ resp_code = resp.getcode()
409
+ if resp_code == 204:
410
+ return {}
411
+ if 200 <= resp_code < 500:
412
+ content = resp.read()
413
+ try:
414
+ return json.loads(content)
415
+ except JSONDecodeError as e:
416
+ log.exception('%s - %s', url, method)
417
+ raise PararamioHTTPRequestException(
418
+ url,
419
+ resp.getcode(),
420
+ 'JSONDecodeError',
421
+ resp.getheaders(),
422
+ BytesIO(content),
423
+ ) from e
424
+ return {}
@@ -0,0 +1,78 @@
1
+ """Common validators for pararamio models."""
2
+
3
+ from .exceptions import PararamioValidationException
4
+
5
+ __all__ = [
6
+ 'validate_filename',
7
+ 'validate_ids_list',
8
+ 'validate_post_load_range',
9
+ ]
10
+
11
+
12
+ def validate_post_load_range(start_post_no: int, end_post_no: int) -> None:
13
+ """
14
+ Validate post loading range parameters.
15
+
16
+ Args:
17
+ start_post_no: Starting post number
18
+ end_post_no: Ending post number
19
+
20
+ Raises:
21
+ PararamioValidationException: If range is invalid
22
+ """
23
+ if (start_post_no < 0 <= end_post_no) or (start_post_no >= 0 > end_post_no):
24
+ msg = 'start_post_no and end_post_no can only be negative or positive at the same time'
25
+ raise PararamioValidationException(msg)
26
+ if 0 > start_post_no > end_post_no:
27
+ msg = 'range start_post_no must be greater then end_post_no'
28
+ raise PararamioValidationException(msg)
29
+ if 0 <= start_post_no > end_post_no:
30
+ msg = 'range start_post_no must be smaller then end_post_no'
31
+ raise PararamioValidationException(msg)
32
+
33
+
34
+ def validate_filename(filename: str) -> None:
35
+ """
36
+ Validate filename for file uploads.
37
+
38
+ Args:
39
+ filename: Name of the file
40
+
41
+ Raises:
42
+ PararamioValidationException: If filename is invalid
43
+ """
44
+ if not filename or not filename.strip():
45
+ msg = 'Filename cannot be empty'
46
+ raise PararamioValidationException(msg)
47
+
48
+ # Add more filename validation as needed
49
+ forbidden_chars = ['<', '>', ':', '"', '|', '?', '*']
50
+ for char in forbidden_chars:
51
+ if char in filename:
52
+ msg = f'Filename cannot contain character: {char}'
53
+ raise PararamioValidationException(msg)
54
+
55
+
56
+ def validate_ids_list(ids: list, max_count: int = 100) -> None:
57
+ """
58
+ Validate list of IDs.
59
+
60
+ Args:
61
+ ids: List of IDs to validate
62
+ max_count: Maximum allowed count
63
+
64
+ Raises:
65
+ PararamioValidationException: If IDs list is invalid
66
+ """
67
+ if not ids:
68
+ msg = 'IDs list cannot be empty'
69
+ raise PararamioValidationException(msg)
70
+
71
+ if len(ids) > max_count:
72
+ msg = f'Too many IDs, maximum {max_count} allowed'
73
+ raise PararamioValidationException(msg)
74
+
75
+ for id_val in ids:
76
+ if not isinstance(id_val, int) or id_val <= 0:
77
+ msg = f'Invalid ID: {id_val}. IDs must be positive integers'
78
+ raise PararamioValidationException(msg)