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.
@@ -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()