atomhttp 1.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.
- atomhttp/__init__.py +76 -0
- atomhttp/adapters/__init__.py +4 -0
- atomhttp/adapters/http_adapter.py +102 -0
- atomhttp/adapters/mock_adapter.py +130 -0
- atomhttp/auth/__init__.py +4 -0
- atomhttp/auth/basic_auth.py +90 -0
- atomhttp/auth/bearer_auth.py +83 -0
- atomhttp/client.py +577 -0
- atomhttp/core/__init__.py +19 -0
- atomhttp/core/adapters.py +687 -0
- atomhttp/core/config.py +186 -0
- atomhttp/core/defaults.py +212 -0
- atomhttp/core/form_data.py +282 -0
- atomhttp/core/request.py +240 -0
- atomhttp/core/response.py +101 -0
- atomhttp/errors/__init__.py +13 -0
- atomhttp/errors/http_errors.py +142 -0
- atomhttp/interceptors/__init__.py +5 -0
- atomhttp/interceptors/manager.py +136 -0
- atomhttp/interceptors/request_interceptor.py +18 -0
- atomhttp/interceptors/response_interceptor.py +18 -0
- atomhttp/progress/__init__.py +3 -0
- atomhttp/progress/upload_progress.py +89 -0
- atomhttp/transforms/__init__.py +5 -0
- atomhttp/transforms/data_serializer.py +96 -0
- atomhttp/transforms/request_transform.py +96 -0
- atomhttp/transforms/response_transform.py +73 -0
- atomhttp/utils/__init__.py +5 -0
- atomhttp/utils/cookies.py +128 -0
- atomhttp/utils/helpers.py +111 -0
- atomhttp/utils/redirect.py +107 -0
- atomhttp-1.0.0.dist-info/METADATA +165 -0
- atomhttp-1.0.0.dist-info/RECORD +36 -0
- atomhttp-1.0.0.dist-info/WHEEL +5 -0
- atomhttp-1.0.0.dist-info/licenses/LICENSE +21 -0
- atomhttp-1.0.0.dist-info/top_level.txt +1 -0
atomhttp/core/config.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Request Configuration Module
|
|
3
|
+
----------------------------
|
|
4
|
+
Defines the configuration data class for HTTP requests in AtomHTTP.
|
|
5
|
+
|
|
6
|
+
This module provides the RequestConfig dataclass which holds all configuration
|
|
7
|
+
parameters for an HTTP request, similar to axios configuration objects.
|
|
8
|
+
It supports timeouts, headers, authentication, proxies, redirects, data
|
|
9
|
+
transformation, and various other request settings.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass, field, asdict
|
|
13
|
+
from typing import Dict, Any, Optional, Callable, Union
|
|
14
|
+
from datetime import timedelta
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class RequestConfig:
|
|
19
|
+
"""
|
|
20
|
+
Configuration object for HTTP requests, inspired by axios config.
|
|
21
|
+
|
|
22
|
+
This dataclass holds all parameters needed to configure and execute
|
|
23
|
+
an HTTP request. It provides a flexible and extensible way to specify
|
|
24
|
+
request options including URL, method, headers, data, timeouts,
|
|
25
|
+
authentication, proxy settings, and response handling.
|
|
26
|
+
|
|
27
|
+
Attributes:
|
|
28
|
+
url (str): Target URL for the request. Can be absolute or relative
|
|
29
|
+
when used with baseURL.
|
|
30
|
+
|
|
31
|
+
method (str): HTTP method (GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS).
|
|
32
|
+
Defaults to 'GET'.
|
|
33
|
+
|
|
34
|
+
baseURL (str): Base URL prepended to relative URLs. Useful for API clients.
|
|
35
|
+
|
|
36
|
+
headers (Dict[str, str]): Custom HTTP headers to send with the request.
|
|
37
|
+
These override default headers.
|
|
38
|
+
|
|
39
|
+
params (Dict[str, Any]): Query parameters to append to the URL.
|
|
40
|
+
Automatically URL-encoded.
|
|
41
|
+
|
|
42
|
+
data (Any): Request body data. Can be dict (JSON), str, bytes,
|
|
43
|
+
FormData, or file-like object.
|
|
44
|
+
|
|
45
|
+
timeout (Union[int, float, timedelta]): Request timeout in seconds.
|
|
46
|
+
Can be integer, float, or timedelta.
|
|
47
|
+
Defaults to 30 seconds.
|
|
48
|
+
|
|
49
|
+
withCredentials (bool): Whether to send cookies with cross-site requests.
|
|
50
|
+
Defaults to False.
|
|
51
|
+
|
|
52
|
+
auth (Optional[Dict[str, str]]): Basic authentication credentials.
|
|
53
|
+
Format: {'username': 'user', 'password': 'pass'}
|
|
54
|
+
|
|
55
|
+
proxy (Optional[Dict[str, Any]]): Proxy server configuration.
|
|
56
|
+
Format: {'host': 'http://proxy:8080', 'auth': {...}}
|
|
57
|
+
|
|
58
|
+
maxRedirects (int): Maximum number of redirects to follow. Defaults to 5.
|
|
59
|
+
|
|
60
|
+
maxContentLength (int): Maximum allowed response body size in bytes.
|
|
61
|
+
-1 means no limit. Defaults to -1.
|
|
62
|
+
|
|
63
|
+
maxBodyLength (int): Maximum allowed request body size in bytes.
|
|
64
|
+
-1 means no limit. Defaults to -1.
|
|
65
|
+
|
|
66
|
+
transformRequest (Optional[Callable]): Function to transform request data
|
|
67
|
+
before sending.
|
|
68
|
+
|
|
69
|
+
transformResponse (Optional[Callable]): Function to transform response data
|
|
70
|
+
before returning.
|
|
71
|
+
|
|
72
|
+
responseType (str): Expected response type. Options: 'json', 'text',
|
|
73
|
+
'blob', 'arraybuffer', 'stream'. Defaults to 'json'.
|
|
74
|
+
|
|
75
|
+
xsrfCookieName (str): Name of the cookie containing the XSRF token.
|
|
76
|
+
Defaults to 'XSRF-TOKEN'.
|
|
77
|
+
|
|
78
|
+
xsrfHeaderName (str): Name of the header to send XSRF token in.
|
|
79
|
+
Defaults to 'X-XSRF-TOKEN'.
|
|
80
|
+
|
|
81
|
+
onUploadProgress (Optional[Callable]): Callback for upload progress.
|
|
82
|
+
Receives (loaded, total) arguments.
|
|
83
|
+
|
|
84
|
+
onDownloadProgress (Optional[Callable]): Callback for download progress.
|
|
85
|
+
Receives (loaded, total) arguments.
|
|
86
|
+
|
|
87
|
+
socketPath (Optional[str]): Unix domain socket path for connection.
|
|
88
|
+
Example: '/var/run/docker.sock'
|
|
89
|
+
|
|
90
|
+
keepAlive (bool): Enable HTTP keep-alive connections. Defaults to True.
|
|
91
|
+
|
|
92
|
+
decompress (bool): Automatically decompress gzip/deflate responses.
|
|
93
|
+
Defaults to True.
|
|
94
|
+
|
|
95
|
+
validateStatus (Optional[Callable]): Function to determine if status code
|
|
96
|
+
should resolve or reject. Receives status.
|
|
97
|
+
|
|
98
|
+
adapter (Optional[Any]): Custom HTTP adapter for request execution.
|
|
99
|
+
|
|
100
|
+
transitional (Dict[str, bool]): Transitional configuration options for
|
|
101
|
+
backward compatibility.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
# Core request parameters
|
|
105
|
+
url: str = ''
|
|
106
|
+
method: str = 'GET'
|
|
107
|
+
baseURL: str = ''
|
|
108
|
+
|
|
109
|
+
# Headers and parameters
|
|
110
|
+
headers: Dict[str, str] = field(default_factory=dict)
|
|
111
|
+
params: Dict[str, Any] = field(default_factory=dict)
|
|
112
|
+
data: Any = None
|
|
113
|
+
|
|
114
|
+
# Timing and connection settings
|
|
115
|
+
timeout: Union[int, float, timedelta] = 30
|
|
116
|
+
withCredentials: bool = False
|
|
117
|
+
|
|
118
|
+
# Authentication and proxy
|
|
119
|
+
auth: Optional[Dict[str, str]] = None
|
|
120
|
+
proxy: Optional[Dict[str, Any]] = None
|
|
121
|
+
|
|
122
|
+
# Request limits and redirects
|
|
123
|
+
maxRedirects: int = 5
|
|
124
|
+
maxContentLength: int = -1
|
|
125
|
+
maxBodyLength: int = -1
|
|
126
|
+
|
|
127
|
+
# Data transformation
|
|
128
|
+
transformRequest: Optional[Callable] = None
|
|
129
|
+
transformResponse: Optional[Callable] = None
|
|
130
|
+
|
|
131
|
+
# Response handling
|
|
132
|
+
responseType: str = 'json'
|
|
133
|
+
|
|
134
|
+
# CSRF protection
|
|
135
|
+
xsrfCookieName: str = 'XSRF-TOKEN'
|
|
136
|
+
xsrfHeaderName: str = 'X-XSRF-TOKEN'
|
|
137
|
+
|
|
138
|
+
# Progress tracking
|
|
139
|
+
onUploadProgress: Optional[Callable] = None
|
|
140
|
+
onDownloadProgress: Optional[Callable] = None
|
|
141
|
+
|
|
142
|
+
# Low-level connection options
|
|
143
|
+
socketPath: Optional[str] = None
|
|
144
|
+
keepAlive: bool = True
|
|
145
|
+
decompress: bool = True
|
|
146
|
+
|
|
147
|
+
# Status validation
|
|
148
|
+
validateStatus: Optional[Callable] = None
|
|
149
|
+
|
|
150
|
+
# Custom adapter
|
|
151
|
+
adapter: Optional[Any] = None
|
|
152
|
+
|
|
153
|
+
# Transitional options for backward compatibility
|
|
154
|
+
transitional: Dict[str, bool] = field(default_factory=lambda: {
|
|
155
|
+
'silentJSONParsing': True,
|
|
156
|
+
'forcedJSONParsing': True,
|
|
157
|
+
'clarifyTimeoutError': False
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
161
|
+
"""
|
|
162
|
+
Convert the configuration to a dictionary, omitting None values.
|
|
163
|
+
|
|
164
|
+
This method serializes the RequestConfig object to a dictionary
|
|
165
|
+
suitable for JSON serialization or for passing to other functions.
|
|
166
|
+
Special handling is applied to timedelta timeout values, converting
|
|
167
|
+
them to seconds.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
Dict[str, Any]: Dictionary representation of the config with all
|
|
171
|
+
non-None values included.
|
|
172
|
+
|
|
173
|
+
Example:
|
|
174
|
+
>>> config = RequestConfig(url='/api', timeout=timedelta(seconds=5))
|
|
175
|
+
>>> config.to_dict()
|
|
176
|
+
{'url': '/api', 'timeout': 5.0}
|
|
177
|
+
"""
|
|
178
|
+
# Convert dataclass to dictionary
|
|
179
|
+
data = asdict(self)
|
|
180
|
+
|
|
181
|
+
# Convert timedelta to seconds if present
|
|
182
|
+
if isinstance(self.timeout, timedelta):
|
|
183
|
+
data['timeout'] = self.timeout.total_seconds()
|
|
184
|
+
|
|
185
|
+
# Remove None values to keep the dictionary clean
|
|
186
|
+
return {k: v for k, v in data.items() if v is not None}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Defaults Configuration Module
|
|
3
|
+
-----------------------------
|
|
4
|
+
Manages default configuration settings for the AtomHTTP client.
|
|
5
|
+
|
|
6
|
+
This module provides the Defaults class which maintains the default
|
|
7
|
+
configuration values for all AtomHTTP client instances. It supports
|
|
8
|
+
merging custom configurations with defaults, updating individual
|
|
9
|
+
settings, and accessing default values as attributes.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from typing import Dict, Any
|
|
13
|
+
from .config import RequestConfig
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Defaults:
|
|
17
|
+
"""
|
|
18
|
+
Default configuration manager for AtomHTTP client instances.
|
|
19
|
+
|
|
20
|
+
This class holds the default configuration that will be applied to
|
|
21
|
+
all requests made by a AtomHTTP client unless overridden. It supports
|
|
22
|
+
updating defaults with custom values, accessing defaults as attributes,
|
|
23
|
+
and converting defaults to dictionary format.
|
|
24
|
+
|
|
25
|
+
The Defaults class is designed to be used internally by the AtomHTTP
|
|
26
|
+
client, but can also be accessed directly for advanced use cases.
|
|
27
|
+
|
|
28
|
+
Features:
|
|
29
|
+
- Centralized default configuration management
|
|
30
|
+
- Deep merging of header dictionaries
|
|
31
|
+
- Attribute-style access to configuration values
|
|
32
|
+
- Support for updating individual settings
|
|
33
|
+
- Dictionary conversion for serialization
|
|
34
|
+
|
|
35
|
+
Attributes:
|
|
36
|
+
_config (RequestConfig): Internal RequestConfig object holding all
|
|
37
|
+
default values.
|
|
38
|
+
|
|
39
|
+
Example:
|
|
40
|
+
>>> defaults = Defaults()
|
|
41
|
+
>>> print(defaults.timeout)
|
|
42
|
+
30
|
|
43
|
+
>>> defaults.timeout = 60
|
|
44
|
+
>>> print(defaults.timeout)
|
|
45
|
+
60
|
|
46
|
+
>>> defaults.update(RequestConfig(headers={'X-Custom': 'value'}))
|
|
47
|
+
>>> print(defaults.headers['X-Custom'])
|
|
48
|
+
'value'
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self):
|
|
52
|
+
"""
|
|
53
|
+
Initialize default configuration with sensible defaults.
|
|
54
|
+
|
|
55
|
+
Default values are chosen to provide a balanced experience
|
|
56
|
+
between security, performance, and compatibility:
|
|
57
|
+
- Accept header accepts JSON, text, and any format
|
|
58
|
+
- User-Agent identifies the client as atomhttp/2.0.0
|
|
59
|
+
- 30 second timeout prevents hanging requests
|
|
60
|
+
- Up to 5 redirects followed automatically
|
|
61
|
+
- JSON response parsing by default
|
|
62
|
+
- CSRF protection enabled with standard header names
|
|
63
|
+
- Credentials not sent cross-origin by default
|
|
64
|
+
- Keep-alive enabled for connection reuse
|
|
65
|
+
- Automatic gzip/deflate decompression enabled
|
|
66
|
+
"""
|
|
67
|
+
self._config = RequestConfig(
|
|
68
|
+
# Default headers for all requests
|
|
69
|
+
headers={
|
|
70
|
+
'Accept': 'application/json, text/plain, */*',
|
|
71
|
+
'User-Agent': 'atomhttp/2.0.0'
|
|
72
|
+
},
|
|
73
|
+
# Request timeout in seconds
|
|
74
|
+
timeout=30,
|
|
75
|
+
# Maximum number of redirects to follow
|
|
76
|
+
maxRedirects=5,
|
|
77
|
+
# Default response parsing type
|
|
78
|
+
responseType='json',
|
|
79
|
+
# CSRF protection cookie name
|
|
80
|
+
xsrfCookieName='XSRF-TOKEN',
|
|
81
|
+
# CSRF protection header name
|
|
82
|
+
xsrfHeaderName='X-XSRF-TOKEN',
|
|
83
|
+
# Do not send credentials cross-origin by default
|
|
84
|
+
withCredentials=False,
|
|
85
|
+
# Enable HTTP keep-alive for connection reuse
|
|
86
|
+
keepAlive=True,
|
|
87
|
+
# Automatically decompress compressed responses
|
|
88
|
+
decompress=True,
|
|
89
|
+
# No base URL by default (use absolute URLs)
|
|
90
|
+
baseURL=''
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def update(self, config: RequestConfig):
|
|
94
|
+
"""
|
|
95
|
+
Update default configuration with values from another config.
|
|
96
|
+
|
|
97
|
+
This method merges the provided configuration into the defaults.
|
|
98
|
+
Headers are merged (custom headers added, existing ones overwritten),
|
|
99
|
+
while other fields are replaced entirely if non-None.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
config (RequestConfig): Configuration containing values to merge
|
|
103
|
+
into the defaults. Only non-None values
|
|
104
|
+
are considered for the update.
|
|
105
|
+
|
|
106
|
+
Example:
|
|
107
|
+
>>> defaults = Defaults()
|
|
108
|
+
>>> custom = RequestConfig(
|
|
109
|
+
... headers={'X-API-Key': '12345'},
|
|
110
|
+
... timeout=15
|
|
111
|
+
... )
|
|
112
|
+
>>> defaults.update(custom)
|
|
113
|
+
>>> print(defaults.timeout) # Updated to 15
|
|
114
|
+
>>> print(defaults.headers['X-API-Key']) # New header added
|
|
115
|
+
"""
|
|
116
|
+
# Convert config to dictionary for iteration
|
|
117
|
+
config_dict = config.to_dict()
|
|
118
|
+
|
|
119
|
+
# Process each key-value pair
|
|
120
|
+
for key, value in config_dict.items():
|
|
121
|
+
# Skip None values (they represent no change)
|
|
122
|
+
if value is not None and hasattr(self._config, key):
|
|
123
|
+
# Special handling for headers: merge dictionaries instead of replacing
|
|
124
|
+
if key == 'headers' and isinstance(value, dict):
|
|
125
|
+
self._config.headers.update(value)
|
|
126
|
+
else:
|
|
127
|
+
# Replace other attributes directly
|
|
128
|
+
setattr(self._config, key, value)
|
|
129
|
+
|
|
130
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
131
|
+
"""
|
|
132
|
+
Convert all default configuration to a dictionary.
|
|
133
|
+
|
|
134
|
+
This method returns a complete dictionary representation of the
|
|
135
|
+
current default configuration, with None values omitted.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
Dict[str, Any]: Dictionary containing all non-None default
|
|
139
|
+
configuration values.
|
|
140
|
+
|
|
141
|
+
Example:
|
|
142
|
+
>>> defaults = Defaults()
|
|
143
|
+
>>> defaults_dict = defaults.to_dict()
|
|
144
|
+
>>> print(defaults_dict.keys())
|
|
145
|
+
dict_keys(['headers', 'timeout', 'maxRedirects', ...])
|
|
146
|
+
"""
|
|
147
|
+
return self._config.to_dict()
|
|
148
|
+
|
|
149
|
+
def __getattr__(self, name):
|
|
150
|
+
"""
|
|
151
|
+
Access configuration values as attributes.
|
|
152
|
+
|
|
153
|
+
This magic method allows direct attribute access to configuration
|
|
154
|
+
values stored in the internal _config object.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
name (str): Name of the configuration attribute to retrieve
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
Any: Value of the requested configuration attribute
|
|
161
|
+
|
|
162
|
+
Raises:
|
|
163
|
+
AttributeError: If the attribute doesn't exist in _config
|
|
164
|
+
|
|
165
|
+
Example:
|
|
166
|
+
>>> defaults = Defaults()
|
|
167
|
+
>>> print(defaults.timeout) # Access via __getattr__
|
|
168
|
+
30
|
|
169
|
+
"""
|
|
170
|
+
return getattr(self._config, name)
|
|
171
|
+
|
|
172
|
+
def __setattr__(self, name, value):
|
|
173
|
+
"""
|
|
174
|
+
Set configuration values as attributes.
|
|
175
|
+
|
|
176
|
+
This magic method routes attribute assignment to the internal
|
|
177
|
+
_config object, except for the special '_config' attribute itself.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
name (str): Name of the configuration attribute to set
|
|
181
|
+
value (Any): Value to assign to the configuration attribute
|
|
182
|
+
|
|
183
|
+
Example:
|
|
184
|
+
>>> defaults = Defaults()
|
|
185
|
+
>>> defaults.timeout = 45 # Sets _config.timeout = 45
|
|
186
|
+
"""
|
|
187
|
+
# Special handling for the internal _config attribute
|
|
188
|
+
if name == '_config':
|
|
189
|
+
super().__setattr__(name, value)
|
|
190
|
+
else:
|
|
191
|
+
# Route all other attribute assignments to the _config object
|
|
192
|
+
setattr(self._config, name, value)
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def baseURL(self) -> str:
|
|
196
|
+
"""
|
|
197
|
+
Get the base URL configured in defaults.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
str: Current base URL value (empty string if not set)
|
|
201
|
+
"""
|
|
202
|
+
return self._config.baseURL
|
|
203
|
+
|
|
204
|
+
@baseURL.setter
|
|
205
|
+
def baseURL(self, value: str):
|
|
206
|
+
"""
|
|
207
|
+
Set the base URL in defaults.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
value (str): New base URL value to set
|
|
211
|
+
"""
|
|
212
|
+
self._config.baseURL = value
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FormData Module
|
|
3
|
+
---------------
|
|
4
|
+
Provides multipart/form-data and URL-encoded form data handling for AtomHTTP.
|
|
5
|
+
|
|
6
|
+
This module implements a FormData class that mimics the browser's FormData API,
|
|
7
|
+
allowing easy construction of form data for HTTP requests. It supports:
|
|
8
|
+
- Multiple values for the same field name
|
|
9
|
+
- File uploads with automatic MIME type detection
|
|
10
|
+
- Mixed field and file data in the same form
|
|
11
|
+
- Streaming of file contents without loading entire files into memory
|
|
12
|
+
- Conversion to both multipart/form-data and application/x-www-form-urlencoded formats
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import io
|
|
16
|
+
import os
|
|
17
|
+
import random
|
|
18
|
+
import string
|
|
19
|
+
from typing import Dict, Any, Optional, Union, Tuple, List
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from urllib.parse import urlencode
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class FormDataItem:
|
|
25
|
+
"""
|
|
26
|
+
Represents a single item in a FormData field.
|
|
27
|
+
|
|
28
|
+
FormData fields can have multiple values (e.g., for multi-select inputs),
|
|
29
|
+
and each value is stored as a FormDataItem. This class holds the value
|
|
30
|
+
along with optional metadata for file uploads.
|
|
31
|
+
|
|
32
|
+
Attributes:
|
|
33
|
+
value (Any): The actual data value (string, bytes, file-like, Path, etc.)
|
|
34
|
+
filename (Optional[str]): Original filename for file uploads
|
|
35
|
+
content_type (Optional[str]): MIME type of the file content
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, value: Any, filename: Optional[str] = None, content_type: Optional[str] = None):
|
|
39
|
+
"""
|
|
40
|
+
Initialize a form data item.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
value (Any): The data value. Can be string, bytes, file object, Path, etc.
|
|
44
|
+
filename (Optional[str]): Original filename (for file uploads)
|
|
45
|
+
content_type (Optional[str]): MIME type (auto-detected if not provided)
|
|
46
|
+
"""
|
|
47
|
+
self.value = value
|
|
48
|
+
self.filename = filename
|
|
49
|
+
self.content_type = content_type
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class FormData:
|
|
53
|
+
"""
|
|
54
|
+
FormData implementation similar to browser FormData API.
|
|
55
|
+
|
|
56
|
+
This class provides a convenient interface for constructing form data
|
|
57
|
+
suitable for HTTP requests, supporting both multipart/form-data (for
|
|
58
|
+
file uploads and mixed content) and URL-encoded (for simple forms).
|
|
59
|
+
|
|
60
|
+
Features:
|
|
61
|
+
- Append multiple values to the same field name
|
|
62
|
+
- Automatic MIME type detection from file extensions
|
|
63
|
+
- Support for various value types (str, bytes, file objects, Path)
|
|
64
|
+
- Streaming of large file contents
|
|
65
|
+
- Boundary generation for multipart requests
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(self):
|
|
69
|
+
"""
|
|
70
|
+
Initialize an empty FormData object.
|
|
71
|
+
|
|
72
|
+
The internal data structure maps field names to lists of FormDataItem
|
|
73
|
+
objects, allowing multiple values per field.
|
|
74
|
+
"""
|
|
75
|
+
self._data: Dict[str, List[FormDataItem]] = {}
|
|
76
|
+
self._boundary: Optional[str] = None
|
|
77
|
+
|
|
78
|
+
def append(self, name: str, value: Any, filename: Optional[str] = None, content_type: Optional[str] = None):
|
|
79
|
+
"""
|
|
80
|
+
Append a value to a form data field.
|
|
81
|
+
|
|
82
|
+
If the field name already exists, the new value is added to the list
|
|
83
|
+
of values for that field (multiple values are allowed).
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
name (str): Field name
|
|
87
|
+
value (Any): Field value (string, bytes, file object, Path, etc.)
|
|
88
|
+
filename (Optional[str]): Original filename (for file uploads)
|
|
89
|
+
content_type (Optional[str]): MIME type (auto-detected from filename if not provided)
|
|
90
|
+
"""
|
|
91
|
+
if name not in self._data:
|
|
92
|
+
self._data[name] = []
|
|
93
|
+
|
|
94
|
+
item = FormDataItem(value, filename, content_type)
|
|
95
|
+
self._data[name].append(item)
|
|
96
|
+
|
|
97
|
+
def delete(self, name: str):
|
|
98
|
+
"""
|
|
99
|
+
Delete all values for a form data field.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
name (str): Field name to remove
|
|
103
|
+
"""
|
|
104
|
+
if name in self._data:
|
|
105
|
+
del self._data[name]
|
|
106
|
+
|
|
107
|
+
def get(self, name: str) -> Optional[Any]:
|
|
108
|
+
"""
|
|
109
|
+
Get the first value of a form data field.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
name (str): Field name to retrieve
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
Optional[Any]: First value of the field, or None if field doesn't exist
|
|
116
|
+
"""
|
|
117
|
+
if name in self._data and self._data[name]:
|
|
118
|
+
return self._data[name][0].value
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
def get_all(self, name: str) -> List[Any]:
|
|
122
|
+
"""
|
|
123
|
+
Get all values of a form data field.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
name (str): Field name to retrieve
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
List[Any]: List of all values for the field (empty list if field doesn't exist)
|
|
130
|
+
"""
|
|
131
|
+
if name in self._data:
|
|
132
|
+
return [item.value for item in self._data[name]]
|
|
133
|
+
return []
|
|
134
|
+
|
|
135
|
+
def has(self, name: str) -> bool:
|
|
136
|
+
"""
|
|
137
|
+
Check if a field exists in the form data.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
name (str): Field name to check
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
bool: True if field exists, False otherwise
|
|
144
|
+
"""
|
|
145
|
+
return name in self._data
|
|
146
|
+
|
|
147
|
+
def keys(self) -> List[str]:
|
|
148
|
+
"""
|
|
149
|
+
Get all field names in the form data.
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
List[str]: List of all field names
|
|
153
|
+
"""
|
|
154
|
+
return list(self._data.keys())
|
|
155
|
+
|
|
156
|
+
def values(self) -> List[Any]:
|
|
157
|
+
"""
|
|
158
|
+
Get all values in the form data (flattened).
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
List[Any]: List of all values (all items from all fields)
|
|
162
|
+
"""
|
|
163
|
+
return [item.value for items in self._data.values() for item in items]
|
|
164
|
+
|
|
165
|
+
def items(self) -> List[Tuple[str, Any]]:
|
|
166
|
+
"""
|
|
167
|
+
Get all field-value pairs (flattened).
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
List[Tuple[str, Any]]: List of (field_name, value) tuples
|
|
171
|
+
"""
|
|
172
|
+
return [(name, item.value) for name, items in self._data.items() for item in items]
|
|
173
|
+
|
|
174
|
+
def _generate_boundary(self) -> str:
|
|
175
|
+
"""
|
|
176
|
+
Generate a unique multipart boundary string.
|
|
177
|
+
|
|
178
|
+
The boundary is a random string that separates different parts of
|
|
179
|
+
a multipart/form-data request. It's designed to be unique enough
|
|
180
|
+
to not appear in the actual data.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
str: Random boundary string
|
|
184
|
+
"""
|
|
185
|
+
return '----WebKitFormBoundary' + ''.join(random.choices(string.ascii_letters + string.digits, k=16))
|
|
186
|
+
|
|
187
|
+
def to_multipart(self) -> Tuple[bytes, str]:
|
|
188
|
+
"""
|
|
189
|
+
Convert form data to multipart/form-data format.
|
|
190
|
+
|
|
191
|
+
This method serializes the FormData object into the multipart format
|
|
192
|
+
required by HTTP requests. Each field becomes a separate part with
|
|
193
|
+
its own headers and content.
|
|
194
|
+
|
|
195
|
+
The format follows RFC 7578 (Returning Values from Forms: multipart/form-data).
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
Tuple[bytes, str]: A tuple containing:
|
|
199
|
+
- bytes: The complete multipart/form-data body
|
|
200
|
+
- str: The boundary string used for the multipart parts
|
|
201
|
+
"""
|
|
202
|
+
if not self._boundary:
|
|
203
|
+
self._boundary = self._generate_boundary()
|
|
204
|
+
|
|
205
|
+
lines = []
|
|
206
|
+
|
|
207
|
+
for name, items in self._data.items():
|
|
208
|
+
for item in items:
|
|
209
|
+
lines.append(f'--{self._boundary}')
|
|
210
|
+
lines.append(f'Content-Disposition: form-data; name="{name}"')
|
|
211
|
+
|
|
212
|
+
if item.filename:
|
|
213
|
+
lines[-1] = f'Content-Disposition: form-data; name="{name}"; filename="{item.filename}"'
|
|
214
|
+
|
|
215
|
+
if item.content_type:
|
|
216
|
+
lines.append(f'Content-Type: {item.content_type}')
|
|
217
|
+
else:
|
|
218
|
+
ext = os.path.splitext(item.filename)[1].lower()
|
|
219
|
+
content_types = {
|
|
220
|
+
'.txt': 'text/plain',
|
|
221
|
+
'.json': 'application/json',
|
|
222
|
+
'.xml': 'application/xml',
|
|
223
|
+
'.html': 'text/html',
|
|
224
|
+
'.css': 'text/css',
|
|
225
|
+
'.js': 'application/javascript',
|
|
226
|
+
'.png': 'image/png',
|
|
227
|
+
'.jpg': 'image/jpeg',
|
|
228
|
+
'.jpeg': 'image/jpeg',
|
|
229
|
+
'.gif': 'image/gif',
|
|
230
|
+
'.pdf': 'application/pdf',
|
|
231
|
+
'.zip': 'application/zip',
|
|
232
|
+
'.mp4': 'video/mp4',
|
|
233
|
+
'.mp3': 'audio/mpeg',
|
|
234
|
+
}
|
|
235
|
+
lines.append(f'Content-Type: {content_types.get(ext, "application/octet-stream")}')
|
|
236
|
+
|
|
237
|
+
lines.append('')
|
|
238
|
+
|
|
239
|
+
if isinstance(item.value, (bytes, bytearray)):
|
|
240
|
+
lines.append(item.value)
|
|
241
|
+
elif isinstance(item.value, (io.IOBase, io.BufferedReader)):
|
|
242
|
+
lines.append(item.value.read())
|
|
243
|
+
elif isinstance(item.value, Path):
|
|
244
|
+
with open(item.value, 'rb') as f:
|
|
245
|
+
lines.append(f.read())
|
|
246
|
+
else:
|
|
247
|
+
lines.append(str(item.value).encode('utf-8'))
|
|
248
|
+
|
|
249
|
+
lines.append('')
|
|
250
|
+
|
|
251
|
+
lines.append(f'--{self._boundary}--')
|
|
252
|
+
lines.append('')
|
|
253
|
+
|
|
254
|
+
result = []
|
|
255
|
+
for line in lines:
|
|
256
|
+
if isinstance(line, bytes):
|
|
257
|
+
result.append(line)
|
|
258
|
+
else:
|
|
259
|
+
result.append(line.encode('utf-8'))
|
|
260
|
+
result.append(b'\r\n')
|
|
261
|
+
|
|
262
|
+
return b''.join(result), self._boundary
|
|
263
|
+
|
|
264
|
+
def to_urlencoded(self) -> str:
|
|
265
|
+
"""
|
|
266
|
+
Convert form data to application/x-www-form-urlencoded format.
|
|
267
|
+
|
|
268
|
+
This method serializes the FormData object into the URL-encoded
|
|
269
|
+
format suitable for HTML forms without file uploads.
|
|
270
|
+
|
|
271
|
+
Note: Only the first value of each field is included in the output.
|
|
272
|
+
Multiple values are not supported in standard URL-encoded forms.
|
|
273
|
+
|
|
274
|
+
Returns:
|
|
275
|
+
str: URL-encoded form data string
|
|
276
|
+
"""
|
|
277
|
+
params = {}
|
|
278
|
+
for name, items in self._data.items():
|
|
279
|
+
if items:
|
|
280
|
+
params[name] = items[0].value
|
|
281
|
+
|
|
282
|
+
return urlencode(params)
|