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,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)
|