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
|
@@ -0,0 +1,687 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HTTP Adapter Module
|
|
3
|
+
-------------------
|
|
4
|
+
Core HTTP transport layer for AtomHTTP client with full feature support including
|
|
5
|
+
keep-alive connections, proxy support, progress tracking, and comprehensive
|
|
6
|
+
error handling.
|
|
7
|
+
|
|
8
|
+
This module provides the main HTTPAdapter class that handles all low-level
|
|
9
|
+
HTTP communication using aiohttp, along with supporting classes for progress
|
|
10
|
+
tracking and mock testing.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import aiohttp
|
|
14
|
+
import asyncio
|
|
15
|
+
import json
|
|
16
|
+
from typing import Optional, Any, Dict, Callable
|
|
17
|
+
from urllib.parse import urlencode
|
|
18
|
+
from .config import RequestConfig
|
|
19
|
+
from .response import Response
|
|
20
|
+
from .form_data import FormData
|
|
21
|
+
from ..errors.http_errors import (
|
|
22
|
+
AtomHTTPNetworkError,
|
|
23
|
+
AtomHTTPRequestError,
|
|
24
|
+
AtomHTTPTimeoutError,
|
|
25
|
+
AtomHTTPError
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class BaseAdapter:
|
|
30
|
+
"""
|
|
31
|
+
Abstract base class for all HTTP adapters.
|
|
32
|
+
|
|
33
|
+
Defines the interface that all adapter implementations must follow.
|
|
34
|
+
Adapters are responsible for the actual transport of HTTP requests.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
async def send(self, config: RequestConfig) -> Response:
|
|
38
|
+
"""
|
|
39
|
+
Execute an HTTP request based on the provided configuration.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
config (RequestConfig): Request configuration object
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Response: The HTTP response
|
|
46
|
+
|
|
47
|
+
Raises:
|
|
48
|
+
NotImplementedError: Must be implemented by subclasses
|
|
49
|
+
"""
|
|
50
|
+
raise NotImplementedError
|
|
51
|
+
|
|
52
|
+
async def close(self):
|
|
53
|
+
"""
|
|
54
|
+
Close the adapter and clean up any resources.
|
|
55
|
+
|
|
56
|
+
This method should be called when the adapter is no longer needed
|
|
57
|
+
to properly release connections and avoid resource leaks.
|
|
58
|
+
"""
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ProgressTracker:
|
|
63
|
+
"""
|
|
64
|
+
Track upload and download progress for HTTP requests.
|
|
65
|
+
|
|
66
|
+
This class provides real-time progress tracking for data transfer operations.
|
|
67
|
+
It works with both upload and download operations, notifying callbacks
|
|
68
|
+
as data is transferred.
|
|
69
|
+
|
|
70
|
+
Attributes:
|
|
71
|
+
callback (Optional[Callable]): Function called on each progress update
|
|
72
|
+
total (int): Total bytes to transfer (0 if unknown)
|
|
73
|
+
loaded (int): Bytes transferred so far
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(self, callback: Optional[Callable] = None, total: int = 0):
|
|
77
|
+
"""
|
|
78
|
+
Initialize a new progress tracker.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
callback (Optional[Callable]): Function receiving (loaded, total) updates
|
|
82
|
+
total (int): Expected total bytes to transfer
|
|
83
|
+
"""
|
|
84
|
+
self.callback = callback
|
|
85
|
+
self.total = total
|
|
86
|
+
self.loaded = 0
|
|
87
|
+
|
|
88
|
+
def update(self, loaded: int):
|
|
89
|
+
"""
|
|
90
|
+
Update the current progress and notify callback if present.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
loaded (int): Current bytes transferred
|
|
94
|
+
"""
|
|
95
|
+
self.loaded = loaded
|
|
96
|
+
if self.callback:
|
|
97
|
+
try:
|
|
98
|
+
self.callback(self.loaded, self.total)
|
|
99
|
+
except Exception:
|
|
100
|
+
# Silently ignore callback errors to prevent breaking the request
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
def reset(self):
|
|
104
|
+
"""Reset the progress counter to zero."""
|
|
105
|
+
self.loaded = 0
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class ProgressReader:
|
|
109
|
+
"""
|
|
110
|
+
Wrapper for reading data with automatic progress tracking.
|
|
111
|
+
|
|
112
|
+
This class wraps a data reader (file, bytes, or any readable object)
|
|
113
|
+
and updates a progress tracker as data is read.
|
|
114
|
+
|
|
115
|
+
Attributes:
|
|
116
|
+
reader: The underlying data reader object
|
|
117
|
+
progress (ProgressTracker): Progress tracker to update during reads
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
def __init__(self, reader, progress: ProgressTracker):
|
|
121
|
+
"""
|
|
122
|
+
Initialize a progress-tracking reader wrapper.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
reader: The reader object to wrap (must support read() and async iteration)
|
|
126
|
+
progress (ProgressTracker): Progress tracker to update
|
|
127
|
+
"""
|
|
128
|
+
self.reader = reader
|
|
129
|
+
self.progress = progress
|
|
130
|
+
|
|
131
|
+
async def read(self, size: int = -1):
|
|
132
|
+
"""
|
|
133
|
+
Read data and update progress.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
size (int): Number of bytes to read (-1 for all)
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
The read data chunk
|
|
140
|
+
"""
|
|
141
|
+
chunk = await self.reader.read(size)
|
|
142
|
+
if chunk:
|
|
143
|
+
self.progress.update(self.progress.loaded + len(chunk))
|
|
144
|
+
return chunk
|
|
145
|
+
|
|
146
|
+
async def __aiter__(self):
|
|
147
|
+
"""
|
|
148
|
+
Support async iteration over the reader with progress tracking.
|
|
149
|
+
|
|
150
|
+
Yields:
|
|
151
|
+
Data chunks as they are read
|
|
152
|
+
"""
|
|
153
|
+
async for chunk in self.reader:
|
|
154
|
+
if chunk:
|
|
155
|
+
self.progress.update(self.progress.loaded + len(chunk))
|
|
156
|
+
yield chunk
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class HTTPAdapter(BaseAdapter):
|
|
160
|
+
"""
|
|
161
|
+
Production HTTP adapter using aiohttp for actual network communication.
|
|
162
|
+
|
|
163
|
+
This adapter provides the complete HTTP client functionality including:
|
|
164
|
+
- Connection pooling and keep-alive support
|
|
165
|
+
- Proxy configuration
|
|
166
|
+
- Unix socket path support
|
|
167
|
+
- Upload and download progress tracking
|
|
168
|
+
- Automatic decompression (gzip/deflate)
|
|
169
|
+
- Configurable timeouts and redirect limits
|
|
170
|
+
- Comprehensive error handling with typed exceptions
|
|
171
|
+
|
|
172
|
+
The adapter maintains persistent sessions across requests for optimal
|
|
173
|
+
performance when multiple requests are made.
|
|
174
|
+
|
|
175
|
+
Attributes:
|
|
176
|
+
_session (Optional[aiohttp.ClientSession]): Persistent HTTP session
|
|
177
|
+
_connector (Optional[aiohttp.TCPConnector]): TCP connector for connection pooling
|
|
178
|
+
_close_lock (asyncio.Lock): Lock for thread-safe session cleanup
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
def __init__(self):
|
|
182
|
+
"""Initialize HTTP adapter with session pooling support."""
|
|
183
|
+
self._session: Optional[aiohttp.ClientSession] = None
|
|
184
|
+
self._connector: Optional[aiohttp.TCPConnector] = None
|
|
185
|
+
self._close_lock = asyncio.Lock()
|
|
186
|
+
|
|
187
|
+
async def _get_session(self, config: RequestConfig) -> aiohttp.ClientSession:
|
|
188
|
+
"""
|
|
189
|
+
Get or create a persistent HTTP session.
|
|
190
|
+
|
|
191
|
+
Sessions are reused across requests for connection pooling and
|
|
192
|
+
keep-alive benefits. Configuration parameters like keep-alive
|
|
193
|
+
and socket path are applied when creating new sessions.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
config (RequestConfig): Request configuration containing connection settings
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
aiohttp.ClientSession: Active HTTP session
|
|
200
|
+
"""
|
|
201
|
+
async with self._close_lock:
|
|
202
|
+
if self._session is None or self._session.closed:
|
|
203
|
+
# Extract connection configuration from request
|
|
204
|
+
keep_alive = getattr(config, 'keepAlive', True)
|
|
205
|
+
socket_path = getattr(config, 'socketPath', None)
|
|
206
|
+
|
|
207
|
+
# Configure TCP connector with appropriate settings
|
|
208
|
+
connector_kwargs = {
|
|
209
|
+
'keepalive_timeout': 30 if keep_alive else 0,
|
|
210
|
+
'force_close': not keep_alive,
|
|
211
|
+
'enable_cleanup_closed': True,
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
# Add Unix socket support if specified
|
|
215
|
+
if socket_path:
|
|
216
|
+
connector_kwargs['socket_path'] = socket_path
|
|
217
|
+
|
|
218
|
+
# Clean up existing connector if present
|
|
219
|
+
if self._connector and not self._connector.closed:
|
|
220
|
+
await self._connector.close()
|
|
221
|
+
|
|
222
|
+
# Create new connector and session
|
|
223
|
+
self._connector = aiohttp.TCPConnector(**connector_kwargs)
|
|
224
|
+
self._session = aiohttp.ClientSession(
|
|
225
|
+
connector=self._connector,
|
|
226
|
+
trust_env=True # Respect HTTP_PROXY environment variables
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
return self._session
|
|
230
|
+
|
|
231
|
+
async def send(self, config: RequestConfig) -> Response:
|
|
232
|
+
"""
|
|
233
|
+
Execute HTTP request with full feature support.
|
|
234
|
+
|
|
235
|
+
This method handles the complete request lifecycle:
|
|
236
|
+
1. Session acquisition
|
|
237
|
+
2. Request data preparation (JSON, FormData, bytes, etc.)
|
|
238
|
+
3. Header configuration and auto-headers
|
|
239
|
+
4. Proxy routing (if configured)
|
|
240
|
+
5. Progress tracking setup
|
|
241
|
+
6. Request execution
|
|
242
|
+
7. Response processing (JSON, text, blob, arraybuffer, stream)
|
|
243
|
+
8. Status validation
|
|
244
|
+
9. Error transformation to AtomHTTP error types
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
config (RequestConfig): Complete request configuration
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
Response: Processed response object
|
|
251
|
+
|
|
252
|
+
Raises:
|
|
253
|
+
AtomHTTPTimeoutError: Request exceeded timeout
|
|
254
|
+
AtomHTTPNetworkError: Network connectivity issues
|
|
255
|
+
AtomHTTPRequestError: Bad request or unexpected response
|
|
256
|
+
"""
|
|
257
|
+
# Convert timeout to seconds and create aiohttp timeout object
|
|
258
|
+
timeout_seconds = float(config.timeout) if config.timeout else 30.0
|
|
259
|
+
timeout = aiohttp.ClientTimeout(total=timeout_seconds)
|
|
260
|
+
|
|
261
|
+
# Get or create persistent session
|
|
262
|
+
session = await self._get_session(config)
|
|
263
|
+
|
|
264
|
+
# Initialize request data variables
|
|
265
|
+
data = None
|
|
266
|
+
json_data = None
|
|
267
|
+
content_type = None
|
|
268
|
+
upload_progress = None
|
|
269
|
+
|
|
270
|
+
# Process request data based on type
|
|
271
|
+
if config.data is not None:
|
|
272
|
+
# Handle FormData (multipart/form-data)
|
|
273
|
+
if isinstance(config.data, FormData):
|
|
274
|
+
data, boundary = config.data.to_multipart()
|
|
275
|
+
content_type = f"multipart/form-data; boundary={boundary}"
|
|
276
|
+
|
|
277
|
+
# Setup upload progress tracking for FormData
|
|
278
|
+
if config.onUploadProgress:
|
|
279
|
+
upload_progress = ProgressTracker(config.onUploadProgress, len(data) if data else 0)
|
|
280
|
+
data = ProgressReader(type('Reader', (), {'read': lambda s, d=data: d})(), upload_progress)
|
|
281
|
+
|
|
282
|
+
# Handle dict or list as JSON by default
|
|
283
|
+
elif isinstance(config.data, (dict, list)):
|
|
284
|
+
json_data = config.data
|
|
285
|
+
content_type = 'application/json'
|
|
286
|
+
|
|
287
|
+
# Setup upload progress for JSON
|
|
288
|
+
if config.onUploadProgress:
|
|
289
|
+
json_str = json.dumps(json_data)
|
|
290
|
+
upload_progress = ProgressTracker(config.onUploadProgress, len(json_str))
|
|
291
|
+
json_data = json_str
|
|
292
|
+
|
|
293
|
+
# Handle string data
|
|
294
|
+
elif isinstance(config.data, str):
|
|
295
|
+
stripped = config.data.strip()
|
|
296
|
+
# Check if string is JSON-like
|
|
297
|
+
if stripped.startswith('{') or stripped.startswith('['):
|
|
298
|
+
try:
|
|
299
|
+
json_data = json.loads(config.data)
|
|
300
|
+
content_type = 'application/json'
|
|
301
|
+
if config.onUploadProgress:
|
|
302
|
+
upload_progress = ProgressTracker(config.onUploadProgress, len(config.data))
|
|
303
|
+
json_data = config.data
|
|
304
|
+
except json.JSONDecodeError:
|
|
305
|
+
# Not valid JSON, treat as plain text
|
|
306
|
+
data = config.data
|
|
307
|
+
if config.onUploadProgress:
|
|
308
|
+
upload_progress = ProgressTracker(config.onUploadProgress, len(config.data))
|
|
309
|
+
data = config.data
|
|
310
|
+
else:
|
|
311
|
+
# Plain text data
|
|
312
|
+
data = config.data
|
|
313
|
+
if config.onUploadProgress:
|
|
314
|
+
upload_progress = ProgressTracker(config.onUploadProgress, len(config.data))
|
|
315
|
+
data = config.data
|
|
316
|
+
|
|
317
|
+
# Handle bytes data
|
|
318
|
+
elif isinstance(config.data, bytes):
|
|
319
|
+
data = config.data
|
|
320
|
+
if config.onUploadProgress:
|
|
321
|
+
upload_progress = ProgressTracker(config.onUploadProgress, len(config.data))
|
|
322
|
+
data = config.data
|
|
323
|
+
|
|
324
|
+
# Convert any other type to string
|
|
325
|
+
else:
|
|
326
|
+
data = str(config.data)
|
|
327
|
+
if config.onUploadProgress:
|
|
328
|
+
upload_progress = ProgressTracker(config.onUploadProgress, len(data))
|
|
329
|
+
|
|
330
|
+
# Prepare request headers
|
|
331
|
+
headers = {}
|
|
332
|
+
if config.headers:
|
|
333
|
+
headers.update(config.headers)
|
|
334
|
+
|
|
335
|
+
# Set Content-Type if not already specified
|
|
336
|
+
if content_type and 'Content-Type' not in headers:
|
|
337
|
+
headers['Content-Type'] = content_type
|
|
338
|
+
|
|
339
|
+
# Add standard X-Requested-With header (identifies AJAX requests)
|
|
340
|
+
headers['X-Requested-With'] = 'XMLHttpRequest'
|
|
341
|
+
|
|
342
|
+
# Build full URL including query parameters
|
|
343
|
+
full_url = config.url
|
|
344
|
+
if config.params and len(config.params) > 0:
|
|
345
|
+
if '?' in full_url:
|
|
346
|
+
full_url = f"{full_url}&{urlencode(config.params)}"
|
|
347
|
+
else:
|
|
348
|
+
full_url = f"{full_url}?{urlencode(config.params)}"
|
|
349
|
+
|
|
350
|
+
# Prepare request arguments
|
|
351
|
+
request_kwargs = {
|
|
352
|
+
'method': config.method,
|
|
353
|
+
'url': full_url,
|
|
354
|
+
'headers': headers,
|
|
355
|
+
'timeout': timeout,
|
|
356
|
+
'max_redirects': config.maxRedirects,
|
|
357
|
+
'ssl': True,
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
# Add request body (either JSON or raw data)
|
|
361
|
+
if json_data is not None:
|
|
362
|
+
request_kwargs['json'] = json_data
|
|
363
|
+
elif data is not None:
|
|
364
|
+
request_kwargs['data'] = data
|
|
365
|
+
|
|
366
|
+
# Configure proxy if specified
|
|
367
|
+
if config.proxy:
|
|
368
|
+
proxy_url = config.proxy.get('host')
|
|
369
|
+
if proxy_url:
|
|
370
|
+
request_kwargs['proxy'] = proxy_url
|
|
371
|
+
if 'auth' in config.proxy:
|
|
372
|
+
auth = config.proxy['auth']
|
|
373
|
+
if auth.get('username') and auth.get('password'):
|
|
374
|
+
request_kwargs['proxy_auth'] = aiohttp.BasicAuth(
|
|
375
|
+
auth['username'], auth['password']
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
# Enable/disable response decompression
|
|
379
|
+
if hasattr(config, 'decompress'):
|
|
380
|
+
request_kwargs['compress'] = config.decompress
|
|
381
|
+
|
|
382
|
+
try:
|
|
383
|
+
# Initialize upload progress (0%)
|
|
384
|
+
if upload_progress:
|
|
385
|
+
upload_progress.update(0)
|
|
386
|
+
|
|
387
|
+
# Execute the HTTP request
|
|
388
|
+
response = await session.request(**request_kwargs)
|
|
389
|
+
|
|
390
|
+
# Mark upload as complete
|
|
391
|
+
if upload_progress and upload_progress.total > 0:
|
|
392
|
+
upload_progress.update(upload_progress.total)
|
|
393
|
+
|
|
394
|
+
# Validate response content length against configured maximum
|
|
395
|
+
if hasattr(config, 'maxContentLength') and config.maxContentLength > 0:
|
|
396
|
+
content_length = response.content_length
|
|
397
|
+
if content_length and content_length > config.maxContentLength:
|
|
398
|
+
response.close()
|
|
399
|
+
raise AtomHTTPRequestError(
|
|
400
|
+
f"Response content length {content_length} exceeds maxContentLength {config.maxContentLength}",
|
|
401
|
+
request=config, config=config
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
# Determine response type (json, text, blob, arraybuffer, stream)
|
|
405
|
+
response_type = getattr(config, 'responseType', 'json')
|
|
406
|
+
|
|
407
|
+
# Setup download progress tracking if callback provided
|
|
408
|
+
if config.onDownloadProgress:
|
|
409
|
+
content_length = response.content_length
|
|
410
|
+
download_progress = ProgressTracker(config.onDownloadProgress, content_length or 0)
|
|
411
|
+
|
|
412
|
+
# Create wrapper response that tracks download progress
|
|
413
|
+
class ProgressResponse:
|
|
414
|
+
def __init__(self, original, progress, response_type):
|
|
415
|
+
self.original = original
|
|
416
|
+
self.progress = progress
|
|
417
|
+
self.status = original.status
|
|
418
|
+
self.reason = original.reason
|
|
419
|
+
self.headers = dict(original.headers)
|
|
420
|
+
self._content = None
|
|
421
|
+
self.response_type = response_type
|
|
422
|
+
|
|
423
|
+
async def _read_with_progress(self):
|
|
424
|
+
"""Read all data while tracking progress."""
|
|
425
|
+
if self._content is not None:
|
|
426
|
+
return self._content
|
|
427
|
+
|
|
428
|
+
data = bytearray()
|
|
429
|
+
async for chunk in self.original.content.iter_chunks():
|
|
430
|
+
chunk_data = chunk[0]
|
|
431
|
+
if chunk_data:
|
|
432
|
+
data.extend(chunk_data)
|
|
433
|
+
self.progress.update(len(data))
|
|
434
|
+
|
|
435
|
+
self._content = bytes(data)
|
|
436
|
+
return self._content
|
|
437
|
+
|
|
438
|
+
async def json(self):
|
|
439
|
+
"""Parse response as JSON."""
|
|
440
|
+
data = await self._read_with_progress()
|
|
441
|
+
return json.loads(data.decode('utf-8'))
|
|
442
|
+
|
|
443
|
+
async def text(self):
|
|
444
|
+
"""Return response as text."""
|
|
445
|
+
data = await self._read_with_progress()
|
|
446
|
+
return data.decode('utf-8')
|
|
447
|
+
|
|
448
|
+
async def read(self):
|
|
449
|
+
"""Return raw bytes."""
|
|
450
|
+
return await self._read_with_progress()
|
|
451
|
+
|
|
452
|
+
async def __aenter__(self):
|
|
453
|
+
return self
|
|
454
|
+
|
|
455
|
+
async def __aexit__(self, *args):
|
|
456
|
+
pass
|
|
457
|
+
|
|
458
|
+
response = ProgressResponse(response, download_progress, response_type)
|
|
459
|
+
|
|
460
|
+
# Parse response body based on specified type
|
|
461
|
+
if response_type == 'json':
|
|
462
|
+
try:
|
|
463
|
+
if hasattr(response, 'json'):
|
|
464
|
+
response_data = await response.json()
|
|
465
|
+
else:
|
|
466
|
+
response_data = await response.json()
|
|
467
|
+
except aiohttp.ContentTypeError:
|
|
468
|
+
# Response isn't JSON, try to parse anyway or return as text
|
|
469
|
+
text = await response.text()
|
|
470
|
+
try:
|
|
471
|
+
response_data = json.loads(text)
|
|
472
|
+
except json.JSONDecodeError:
|
|
473
|
+
response_data = text
|
|
474
|
+
except Exception:
|
|
475
|
+
response_data = await response.text()
|
|
476
|
+
|
|
477
|
+
elif response_type == 'text':
|
|
478
|
+
if hasattr(response, 'text'):
|
|
479
|
+
response_data = await response.text()
|
|
480
|
+
else:
|
|
481
|
+
response_data = await response.text()
|
|
482
|
+
|
|
483
|
+
elif response_type == 'blob':
|
|
484
|
+
# Return as raw bytes
|
|
485
|
+
if hasattr(response, 'read'):
|
|
486
|
+
response_data = await response.read()
|
|
487
|
+
else:
|
|
488
|
+
response_data = await response.read()
|
|
489
|
+
|
|
490
|
+
elif response_type == 'arraybuffer':
|
|
491
|
+
# Return as raw bytes (alias for blob)
|
|
492
|
+
if hasattr(response, 'read'):
|
|
493
|
+
response_data = await response.read()
|
|
494
|
+
else:
|
|
495
|
+
response_data = await response.read()
|
|
496
|
+
|
|
497
|
+
elif response_type == 'stream':
|
|
498
|
+
# Return the raw stream for large data processing
|
|
499
|
+
if hasattr(response, 'content'):
|
|
500
|
+
response_data = response.content
|
|
501
|
+
else:
|
|
502
|
+
response_data = response.content
|
|
503
|
+
|
|
504
|
+
else:
|
|
505
|
+
# Default to text
|
|
506
|
+
response_data = await response.text()
|
|
507
|
+
|
|
508
|
+
# Extract response headers
|
|
509
|
+
if hasattr(response, 'headers'):
|
|
510
|
+
response_headers = dict(response.headers)
|
|
511
|
+
else:
|
|
512
|
+
response_headers = {}
|
|
513
|
+
|
|
514
|
+
# Validate HTTP status code if custom validator provided
|
|
515
|
+
if config.validateStatus:
|
|
516
|
+
if not config.validateStatus(response.status):
|
|
517
|
+
error = AtomHTTPRequestError(
|
|
518
|
+
f"Request failed with status code {response.status}",
|
|
519
|
+
request=config, config=config, response=response
|
|
520
|
+
)
|
|
521
|
+
error.code = f"ERR_BAD_{response.status}"
|
|
522
|
+
error.response = response
|
|
523
|
+
raise error
|
|
524
|
+
|
|
525
|
+
# Return wrapped response object
|
|
526
|
+
return Response(
|
|
527
|
+
data=response_data,
|
|
528
|
+
status=response.status,
|
|
529
|
+
status_text=response.reason,
|
|
530
|
+
headers=response_headers,
|
|
531
|
+
config=config,
|
|
532
|
+
request=config
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
except asyncio.TimeoutError:
|
|
536
|
+
# Convert asyncio timeout to AtomHTTP timeout error
|
|
537
|
+
error = AtomHTTPTimeoutError(f"Request timeout after {timeout_seconds}s", config=config)
|
|
538
|
+
error.code = 'ECONNABORTED'
|
|
539
|
+
raise error
|
|
540
|
+
|
|
541
|
+
except aiohttp.ClientConnectorError as e:
|
|
542
|
+
# Network connection issues (DNS failure, refused connection, etc.)
|
|
543
|
+
error = AtomHTTPNetworkError(f"Connection error: {str(e)}", config=config)
|
|
544
|
+
error.code = 'ERR_NETWORK'
|
|
545
|
+
raise error
|
|
546
|
+
|
|
547
|
+
except aiohttp.ClientResponseError as e:
|
|
548
|
+
# HTTP response error (4xx, 5xx) that wasn't caught by validateStatus
|
|
549
|
+
if config.validateStatus and not config.validateStatus(e.status):
|
|
550
|
+
raise
|
|
551
|
+
error = AtomHTTPRequestError(f"Response error: {str(e)}", request=config, config=config)
|
|
552
|
+
error.code = f"ERR_BAD_{e.status}"
|
|
553
|
+
error.response = e
|
|
554
|
+
raise error
|
|
555
|
+
|
|
556
|
+
except aiohttp.ClientError as e:
|
|
557
|
+
# Generic aiohttp client error
|
|
558
|
+
error = AtomHTTPNetworkError(f"Network error: {str(e)}", config=config)
|
|
559
|
+
error.code = 'ERR_NETWORK'
|
|
560
|
+
raise error
|
|
561
|
+
|
|
562
|
+
except AtomHTTPError:
|
|
563
|
+
# Re-raise AtomHTTP errors without modification
|
|
564
|
+
raise
|
|
565
|
+
|
|
566
|
+
except Exception as e:
|
|
567
|
+
# Convert any other exception to AtomHTTP request error
|
|
568
|
+
error = AtomHTTPRequestError(str(e), request=config, config=config)
|
|
569
|
+
error.code = 'ERR_BAD_REQUEST'
|
|
570
|
+
raise error
|
|
571
|
+
|
|
572
|
+
async def close(self):
|
|
573
|
+
"""
|
|
574
|
+
Close the HTTP adapter and release all resources.
|
|
575
|
+
|
|
576
|
+
This method properly closes the underlying aiohttp session and
|
|
577
|
+
TCP connector, ensuring all connections are properly cleaned up
|
|
578
|
+
to prevent resource leaks.
|
|
579
|
+
"""
|
|
580
|
+
async with self._close_lock:
|
|
581
|
+
try:
|
|
582
|
+
if self._session and not self._session.closed:
|
|
583
|
+
await self._session.close()
|
|
584
|
+
if self._connector and not self._connector.closed:
|
|
585
|
+
await self._connector.close()
|
|
586
|
+
except Exception:
|
|
587
|
+
# Silently ignore cleanup errors
|
|
588
|
+
pass
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
class MockAdapter(BaseAdapter):
|
|
592
|
+
"""
|
|
593
|
+
Mock adapter for testing and development without real network calls.
|
|
594
|
+
|
|
595
|
+
This adapter stores predefined responses and returns them when matching
|
|
596
|
+
requests are made. It enables isolated testing of code that depends on
|
|
597
|
+
HTTP responses without requiring external services or network connectivity.
|
|
598
|
+
|
|
599
|
+
Features:
|
|
600
|
+
- Register mock responses for specific HTTP methods and URLs
|
|
601
|
+
- Customizable response data, status codes, and headers
|
|
602
|
+
- Automatic 404 for unregistered endpoints
|
|
603
|
+
- Case-insensitive method matching (GET/get/Get all work)
|
|
604
|
+
- Clear all mocks with clear() method
|
|
605
|
+
|
|
606
|
+
Attributes:
|
|
607
|
+
_responses (Dict[str, Dict]): Internal storage of registered mock responses
|
|
608
|
+
"""
|
|
609
|
+
|
|
610
|
+
def __init__(self):
|
|
611
|
+
"""Initialize empty mock adapter with no registered responses."""
|
|
612
|
+
self._responses: Dict[str, Dict] = {}
|
|
613
|
+
|
|
614
|
+
def on(self, method: str, url: str, response_data: Any, status: int = 200, headers: Dict = None):
|
|
615
|
+
"""
|
|
616
|
+
Register a mock response for a specific HTTP method and URL.
|
|
617
|
+
|
|
618
|
+
When a request matches the registered method and URL, the specified
|
|
619
|
+
response will be returned instead of making a network request.
|
|
620
|
+
|
|
621
|
+
Args:
|
|
622
|
+
method (str): HTTP method (GET, POST, PUT, DELETE, etc.)
|
|
623
|
+
url (str): Full URL that this mock should respond to
|
|
624
|
+
response_data (Any): Data to return as the response body
|
|
625
|
+
status (int): HTTP status code (default: 200)
|
|
626
|
+
headers (Dict, optional): Custom response headers
|
|
627
|
+
"""
|
|
628
|
+
# Use uppercase for case-insensitive method matching
|
|
629
|
+
key = f"{method.upper()}:{url}"
|
|
630
|
+
self._responses[key] = {
|
|
631
|
+
'data': response_data,
|
|
632
|
+
'status': status,
|
|
633
|
+
'status_text': 'OK' if status == 200 else 'Error',
|
|
634
|
+
'headers': headers or {}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
async def send(self, config: RequestConfig) -> Response:
|
|
638
|
+
"""
|
|
639
|
+
Return a mock response for the request if registered.
|
|
640
|
+
|
|
641
|
+
Args:
|
|
642
|
+
config (RequestConfig): Request configuration containing method and URL
|
|
643
|
+
|
|
644
|
+
Returns:
|
|
645
|
+
Response: Registered mock response or 404 error if not found
|
|
646
|
+
"""
|
|
647
|
+
# Build lookup key with uppercase method for case-insensitive matching
|
|
648
|
+
key = f"{config.method.upper()}:{config.url}"
|
|
649
|
+
|
|
650
|
+
if key in self._responses:
|
|
651
|
+
mock = self._responses[key]
|
|
652
|
+
return Response(
|
|
653
|
+
data=mock['data'],
|
|
654
|
+
status=mock['status'],
|
|
655
|
+
status_text=mock['status_text'],
|
|
656
|
+
headers=mock['headers'],
|
|
657
|
+
config=config,
|
|
658
|
+
request=config
|
|
659
|
+
)
|
|
660
|
+
else:
|
|
661
|
+
# Return 404 for unregistered endpoints
|
|
662
|
+
return Response(
|
|
663
|
+
data={'error': f'No mock found for {key}'},
|
|
664
|
+
status=404,
|
|
665
|
+
status_text='Not Found',
|
|
666
|
+
headers={},
|
|
667
|
+
config=config,
|
|
668
|
+
request=config
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
async def close(self):
|
|
672
|
+
"""
|
|
673
|
+
Close the mock adapter.
|
|
674
|
+
|
|
675
|
+
Mock adapter has no resources to clean up, but this method exists
|
|
676
|
+
for interface compliance with BaseAdapter.
|
|
677
|
+
"""
|
|
678
|
+
pass
|
|
679
|
+
|
|
680
|
+
def clear(self):
|
|
681
|
+
"""
|
|
682
|
+
Clear all registered mock responses.
|
|
683
|
+
|
|
684
|
+
This method removes all previously registered mocks, allowing the
|
|
685
|
+
adapter to be reused for a different test scenario.
|
|
686
|
+
"""
|
|
687
|
+
self._responses.clear()
|