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/client.py ADDED
@@ -0,0 +1,577 @@
1
+ """
2
+ AtomHTTP Client Module
3
+ ----------------------
4
+ Main HTTP client class for the AtomHTTP library, providing a comprehensive
5
+ interface for making HTTP requests with support for interceptors, progress
6
+ tracking, FormData, concurrent requests, and extensive configuration options.
7
+
8
+ This module contains the AtomHTTP client class which serves as the primary
9
+ entry point for all HTTP operations. It provides a clean, axios-like API
10
+ with support for all standard HTTP methods, request/response interceptors,
11
+ upload/download progress tracking, automatic JSON serialization, and more.
12
+ """
13
+
14
+ import asyncio
15
+ from typing import Dict, Any, Optional, Callable, List, Union
16
+ from urllib.parse import urljoin, urlencode, parse_qs
17
+ from .core.request import RequestHandler
18
+ from .core.response import Response
19
+ from .core.config import RequestConfig
20
+ from .core.defaults import Defaults
21
+ from .core.form_data import FormData
22
+ from .interceptors.manager import InterceptorManager
23
+ from .errors.http_errors import AtomHTTPError, AtomHTTPRequestError
24
+
25
+
26
+ class AtomHTTP:
27
+ """
28
+ Main HTTP client class providing a comprehensive interface for HTTP requests.
29
+
30
+ This client is the primary entry point for the AtomHTTP library. It supports:
31
+ - All HTTP methods (GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS)
32
+ - Request and response interceptors
33
+ - Upload and download progress tracking
34
+ - FormData (multipart/form-data) support
35
+ - Automatic JSON serialization/deserialization
36
+ - Base URL configuration
37
+ - Custom headers and query parameters
38
+ - Timeout and redirect configuration
39
+ - Concurrent request helpers (all, spread)
40
+ - Type hints for better IDE support
41
+
42
+ The client maintains default configuration that applies to all requests,
43
+ which can be overridden on a per-request basis. It also manages a session
44
+ with connection pooling for optimal performance.
45
+
46
+ Attributes:
47
+ defaults (Defaults): Default configuration for all requests
48
+ interceptors (InterceptorManager): Manager for request/response interceptors
49
+ _request_handler (RequestHandler): Internal handler for request execution
50
+
51
+ Example:
52
+ >>> client = AtomHTTP({'baseURL': 'https://api.example.com', 'timeout': 30})
53
+ >>> response = await client.get('/users', params={'page': 1})
54
+ >>> print(response.status, response.data)
55
+ """
56
+
57
+ def __init__(self, config: Optional[Union[RequestConfig, Dict]] = None):
58
+ """
59
+ Initialize a new AtomHTTP client with optional configuration.
60
+
61
+ Args:
62
+ config (Optional[Union[RequestConfig, Dict]]): Initial configuration
63
+ for the client. Can be a RequestConfig object or a dictionary
64
+ with configuration keys. If None, defaults are used.
65
+
66
+ Example:
67
+ >>> # Using dictionary
68
+ >>> client = AtomHTTP({'baseURL': 'https://api.example.com', 'timeout': 10})
69
+ >>>
70
+ >>> # Using RequestConfig object
71
+ >>> config = RequestConfig(baseURL='https://api.example.com', timeout=10)
72
+ >>> client = AtomHTTP(config)
73
+ """
74
+ # Initialize default configuration
75
+ self.defaults = Defaults()
76
+
77
+ # Apply user configuration if provided
78
+ if config:
79
+ if isinstance(config, dict):
80
+ config_obj = RequestConfig(**config)
81
+ else:
82
+ config_obj = config
83
+ self.defaults.update(config_obj)
84
+
85
+ # Initialize interceptor manager and request handler
86
+ self.interceptors = InterceptorManager()
87
+ self._request_handler = RequestHandler(self.defaults, self.interceptors)
88
+
89
+ async def request(self, config: Union[RequestConfig, Dict]) -> Response:
90
+ """
91
+ Make an HTTP request with the provided configuration.
92
+
93
+ This is the core method for making requests. All other HTTP methods
94
+ (get, post, etc.) eventually call this method. It supports full
95
+ configuration including custom adapters, interceptors, and transformers.
96
+
97
+ Args:
98
+ config (Union[RequestConfig, Dict]): Request configuration. Can be
99
+ a RequestConfig object or a dictionary with configuration keys.
100
+
101
+ Returns:
102
+ Response: The HTTP response object containing data, status, headers
103
+
104
+ Example:
105
+ >>> response = await client.request({
106
+ ... 'method': 'POST',
107
+ ... 'url': '/users',
108
+ ... 'data': {'name': 'John'},
109
+ ... 'headers': {'X-Custom': 'value'}
110
+ ... })
111
+ """
112
+ # Convert dictionary to RequestConfig if needed
113
+ if isinstance(config, dict):
114
+ config = RequestConfig(**config)
115
+
116
+ # Merge with defaults and build full URL
117
+ merged_config = self._merge_config(config)
118
+ final_url = self._build_full_url(merged_config)
119
+ merged_config.url = final_url
120
+
121
+ # Execute the request through the handler
122
+ return await self._request_handler.execute(merged_config)
123
+
124
+ async def get(
125
+ self,
126
+ url: str,
127
+ params: Optional[Dict] = None,
128
+ response_type: str = 'json',
129
+ on_download_progress: Optional[Callable] = None,
130
+ **kwargs
131
+ ) -> Response:
132
+ """
133
+ Make an HTTP GET request.
134
+
135
+ Args:
136
+ url (str): Request URL (absolute or relative to baseURL)
137
+ params (Optional[Dict]): Query parameters to append to URL
138
+ response_type (str): Expected response type ('json', 'text', 'blob',
139
+ 'arraybuffer', 'stream')
140
+ on_download_progress (Optional[Callable]): Callback for download progress
141
+ **kwargs: Additional request configuration options
142
+
143
+ Returns:
144
+ Response: HTTP response object
145
+ """
146
+ config = RequestConfig(
147
+ url=url,
148
+ method='GET',
149
+ params=params or {},
150
+ responseType=response_type,
151
+ onDownloadProgress=on_download_progress,
152
+ **kwargs
153
+ )
154
+ return await self.request(config)
155
+
156
+ async def post(
157
+ self,
158
+ url: str,
159
+ data: Any = None,
160
+ response_type: str = 'json',
161
+ on_upload_progress: Optional[Callable] = None,
162
+ on_download_progress: Optional[Callable] = None,
163
+ **kwargs
164
+ ) -> Response:
165
+ """
166
+ Make an HTTP POST request.
167
+
168
+ Args:
169
+ url (str): Request URL
170
+ data (Any): Request body (dict for JSON, FormData, bytes, etc.)
171
+ response_type (str): Expected response type
172
+ on_upload_progress (Optional[Callable]): Callback for upload progress
173
+ on_download_progress (Optional[Callable]): Callback for download progress
174
+ **kwargs: Additional request configuration options
175
+
176
+ Returns:
177
+ Response: HTTP response object
178
+ """
179
+ config = RequestConfig(
180
+ url=url,
181
+ method='POST',
182
+ data=data,
183
+ responseType=response_type,
184
+ onUploadProgress=on_upload_progress,
185
+ onDownloadProgress=on_download_progress,
186
+ **kwargs
187
+ )
188
+ return await self.request(config)
189
+
190
+ async def put(
191
+ self,
192
+ url: str,
193
+ data: Any = None,
194
+ response_type: str = 'json',
195
+ on_upload_progress: Optional[Callable] = None,
196
+ on_download_progress: Optional[Callable] = None,
197
+ **kwargs
198
+ ) -> Response:
199
+ """
200
+ Make an HTTP PUT request.
201
+
202
+ Args:
203
+ url (str): Request URL
204
+ data (Any): Request body
205
+ response_type (str): Expected response type
206
+ on_upload_progress (Optional[Callable]): Callback for upload progress
207
+ on_download_progress (Optional[Callable]): Callback for download progress
208
+ **kwargs: Additional request configuration options
209
+
210
+ Returns:
211
+ Response: HTTP response object
212
+ """
213
+ config = RequestConfig(
214
+ url=url,
215
+ method='PUT',
216
+ data=data,
217
+ responseType=response_type,
218
+ onUploadProgress=on_upload_progress,
219
+ onDownloadProgress=on_download_progress,
220
+ **kwargs
221
+ )
222
+ return await self.request(config)
223
+
224
+ async def patch(
225
+ self,
226
+ url: str,
227
+ data: Any = None,
228
+ response_type: str = 'json',
229
+ on_upload_progress: Optional[Callable] = None,
230
+ on_download_progress: Optional[Callable] = None,
231
+ **kwargs
232
+ ) -> Response:
233
+ """
234
+ Make an HTTP PATCH request.
235
+
236
+ Args:
237
+ url (str): Request URL
238
+ data (Any): Request body
239
+ response_type (str): Expected response type
240
+ on_upload_progress (Optional[Callable]): Callback for upload progress
241
+ on_download_progress (Optional[Callable]): Callback for download progress
242
+ **kwargs: Additional request configuration options
243
+
244
+ Returns:
245
+ Response: HTTP response object
246
+ """
247
+ config = RequestConfig(
248
+ url=url,
249
+ method='PATCH',
250
+ data=data,
251
+ responseType=response_type,
252
+ onUploadProgress=on_upload_progress,
253
+ onDownloadProgress=on_download_progress,
254
+ **kwargs
255
+ )
256
+ return await self.request(config)
257
+
258
+ async def delete(
259
+ self,
260
+ url: str,
261
+ response_type: str = 'json',
262
+ on_download_progress: Optional[Callable] = None,
263
+ **kwargs
264
+ ) -> Response:
265
+ """
266
+ Make an HTTP DELETE request.
267
+
268
+ Args:
269
+ url (str): Request URL
270
+ response_type (str): Expected response type
271
+ on_download_progress (Optional[Callable]): Callback for download progress
272
+ **kwargs: Additional request configuration options
273
+
274
+ Returns:
275
+ Response: HTTP response object
276
+ """
277
+ config = RequestConfig(
278
+ url=url,
279
+ method='DELETE',
280
+ responseType=response_type,
281
+ onDownloadProgress=on_download_progress,
282
+ **kwargs
283
+ )
284
+ return await self.request(config)
285
+
286
+ async def head(
287
+ self,
288
+ url: str,
289
+ response_type: str = 'json',
290
+ on_download_progress: Optional[Callable] = None,
291
+ **kwargs
292
+ ) -> Response:
293
+ """
294
+ Make an HTTP HEAD request.
295
+
296
+ Args:
297
+ url (str): Request URL
298
+ response_type (str): Expected response type
299
+ on_download_progress (Optional[Callable]): Callback for download progress
300
+ **kwargs: Additional request configuration options
301
+
302
+ Returns:
303
+ Response: HTTP response object
304
+ """
305
+ config = RequestConfig(
306
+ url=url,
307
+ method='HEAD',
308
+ responseType=response_type,
309
+ onDownloadProgress=on_download_progress,
310
+ **kwargs
311
+ )
312
+ return await self.request(config)
313
+
314
+ async def options(
315
+ self,
316
+ url: str,
317
+ response_type: str = 'json',
318
+ on_download_progress: Optional[Callable] = None,
319
+ **kwargs
320
+ ) -> Response:
321
+ """
322
+ Make an HTTP OPTIONS request.
323
+
324
+ Args:
325
+ url (str): Request URL
326
+ response_type (str): Expected response type
327
+ on_download_progress (Optional[Callable]): Callback for download progress
328
+ **kwargs: Additional request configuration options
329
+
330
+ Returns:
331
+ Response: HTTP response object
332
+ """
333
+ config = RequestConfig(
334
+ url=url,
335
+ method='OPTIONS',
336
+ responseType=response_type,
337
+ onDownloadProgress=on_download_progress,
338
+ **kwargs
339
+ )
340
+ return await self.request(config)
341
+
342
+ def _merge_config(self, config: RequestConfig) -> RequestConfig:
343
+ """
344
+ Merge user configuration with defaults.
345
+
346
+ This method combines the provided request configuration with the
347
+ client's default configuration. Headers and params are merged
348
+ (custom values override defaults), while other fields are replaced
349
+ if present.
350
+
351
+ Args:
352
+ config (RequestConfig): User-provided request configuration
353
+
354
+ Returns:
355
+ RequestConfig: Merged configuration
356
+ """
357
+ # Start with a copy of defaults
358
+ merged = RequestConfig(**self.defaults.to_dict())
359
+
360
+ # Merge all attributes from user config
361
+ config_dict = config.to_dict()
362
+ for key, value in config_dict.items():
363
+ if value is not None:
364
+ if key == 'headers' and isinstance(value, dict):
365
+ if merged.headers is None:
366
+ merged.headers = {}
367
+ merged.headers.update(value)
368
+ elif key == 'params' and isinstance(value, dict):
369
+ if merged.params is None:
370
+ merged.params = {}
371
+ merged.params.update(value)
372
+ else:
373
+ setattr(merged, key, value)
374
+
375
+ # Ensure baseURL is properly inherited from defaults
376
+ if not merged.baseURL and hasattr(self.defaults, 'baseURL') and self.defaults.baseURL:
377
+ merged.baseURL = self.defaults.baseURL
378
+
379
+ return merged
380
+
381
+ def _build_full_url(self, config: RequestConfig) -> str:
382
+ """
383
+ Build a complete URL from baseURL, path, and query parameters.
384
+
385
+ This method handles:
386
+ - Absolute URLs (ignores baseURL)
387
+ - Relative URLs (combines with baseURL)
388
+ - Query parameter merging (preserves existing query string)
389
+
390
+ Args:
391
+ config (RequestConfig): Configuration containing URL and parameters
392
+
393
+ Returns:
394
+ str: Complete URL with base and query parameters
395
+
396
+ Raises:
397
+ AtomHTTPRequestError: If relative URL is used without baseURL
398
+ """
399
+ url = config.url
400
+ base_url = config.baseURL
401
+
402
+ # Combine baseURL and relative path
403
+ if base_url and url:
404
+ if url.startswith(('http://', 'https://')):
405
+ final_url = url
406
+ else:
407
+ base = base_url.rstrip('/')
408
+ path = url.lstrip('/')
409
+ final_url = f"{base}/{path}"
410
+ elif base_url and not url:
411
+ final_url = base_url
412
+ elif url:
413
+ if url.startswith(('http://', 'https://')):
414
+ final_url = url
415
+ else:
416
+ raise AtomHTTPRequestError(
417
+ f"Cannot make request to relative URL '{url}' without baseURL",
418
+ request=config,
419
+ config=config
420
+ )
421
+ else:
422
+ raise AtomHTTPRequestError("URL is required", request=config, config=config)
423
+
424
+ # Append or merge query parameters
425
+ if config.params and len(config.params) > 0:
426
+ if '?' in final_url:
427
+ base_part, existing_params = final_url.split('?', 1)
428
+ existing_dict = parse_qs(existing_params, keep_blank_values=True)
429
+ for key, value in config.params.items():
430
+ existing_dict[key] = [str(value)]
431
+ flat_params = {}
432
+ for key, values in existing_dict.items():
433
+ flat_params[key] = values[0] if len(values) == 1 else values
434
+ query_string = urlencode(flat_params, doseq=True)
435
+ final_url = f"{base_part}?{query_string}"
436
+ else:
437
+ query_string = urlencode(config.params)
438
+ final_url = f"{final_url}?{query_string}"
439
+
440
+ return final_url
441
+
442
+ @staticmethod
443
+ def all(requests: List[asyncio.Task]) -> asyncio.Future:
444
+ """
445
+ Execute multiple requests concurrently.
446
+
447
+ This method waits for all provided coroutines/tasks to complete
448
+ and returns their results as a list. Similar to Promise.all() in
449
+ JavaScript.
450
+
451
+ Args:
452
+ requests (List[asyncio.Task]): List of coroutines or tasks to execute
453
+
454
+ Returns:
455
+ asyncio.Future: Future that resolves to list of all responses
456
+
457
+ Example:
458
+ >>> tasks = [
459
+ ... client.get('/users/1'),
460
+ ... client.get('/users/2'),
461
+ ... client.get('/users/3')
462
+ ... ]
463
+ >>> responses = await AtomHTTP.all(tasks)
464
+ """
465
+ return asyncio.gather(*requests)
466
+
467
+ @staticmethod
468
+ async def spread(callback: Callable, *responses):
469
+ """
470
+ Spread array of responses to callback function arguments.
471
+
472
+ This method takes a list of responses and passes them as individual
473
+ arguments to the callback function. Similar to axios.spread().
474
+
475
+ Args:
476
+ callback (Callable): Function that receives individual response arguments
477
+ *responses: Variable number of response objects
478
+
479
+ Returns:
480
+ Any: Result of the callback function
481
+
482
+ Example:
483
+ >>> def process(res1, res2, res3):
484
+ ... return [res1.status, res2.status, res3.status]
485
+ >>>
486
+ >>> responses = await AtomHTTP.all(tasks)
487
+ >>> statuses = await AtomHTTP.spread(process, *responses)
488
+ """
489
+ return callback(*responses)
490
+
491
+ def get_uri(self, config: Union[RequestConfig, Dict]) -> str:
492
+ """
493
+ Generate the full URI for a request configuration without executing it.
494
+
495
+ This method is useful for debugging or when you need to see the
496
+ final URL that would be used for a request.
497
+
498
+ Args:
499
+ config (Union[RequestConfig, Dict]): Request configuration
500
+
501
+ Returns:
502
+ str: Full URI with baseURL and query parameters applied
503
+
504
+ Example:
505
+ >>> uri = client.get_uri({
506
+ ... 'url': '/users',
507
+ ... 'params': {'page': 1, 'limit': 10}
508
+ ... })
509
+ >>> print(uri)
510
+ 'https://api.example.com/users?page=1&limit=10'
511
+ """
512
+ # Convert dictionary to RequestConfig if needed
513
+ if isinstance(config, dict):
514
+ config = RequestConfig(**config)
515
+
516
+ # Merge with defaults and build URL
517
+ if hasattr(self, 'defaults'):
518
+ merged = self._merge_config(config)
519
+ else:
520
+ merged = config
521
+
522
+ return self._build_full_url(merged)
523
+
524
+ def is_atomhttp_error(self, error: Exception) -> bool:
525
+ """
526
+ Check if an exception is a AtomHTTP error.
527
+
528
+ This method is useful for error handling to distinguish between
529
+ AtomHTTP-specific errors and other exceptions.
530
+
531
+ Args:
532
+ error (Exception): Exception to check
533
+
534
+ Returns:
535
+ bool: True if the error is a AtomHTTP error, False otherwise
536
+
537
+ Example:
538
+ >>> try:
539
+ ... await client.get('https://invalid.com')
540
+ ... except Exception as e:
541
+ ... if client.is_atomhttp_error(e):
542
+ ... print(f"AtomHTTP error: {e.code}")
543
+ """
544
+ return isinstance(error, AtomHTTPError)
545
+
546
+ @staticmethod
547
+ def FormData() -> FormData:
548
+ """
549
+ Create a new FormData instance for multipart/form-data requests.
550
+
551
+ Returns:
552
+ FormData: New FormData object for building form data with files
553
+
554
+ Example:
555
+ >>> form = AtomHTTP.FormData()
556
+ >>> form.append('username', 'john')
557
+ >>> form.append('avatar', open('photo.jpg', 'rb'), filename='photo.jpg')
558
+ >>> response = await client.post('/upload', data=form)
559
+ """
560
+ return FormData()
561
+
562
+ async def close(self) -> None:
563
+ """
564
+ Close the client and clean up resources.
565
+
566
+ This method should be called when the client is no longer needed
567
+ to properly close connections and release system resources.
568
+
569
+ Example:
570
+ >>> client = AtomHTTP()
571
+ >>> try:
572
+ ... response = await client.get('/data')
573
+ ... finally:
574
+ ... await client.close()
575
+ """
576
+ if hasattr(self._request_handler, 'default_adapter'):
577
+ await self._request_handler.default_adapter.close()
@@ -0,0 +1,19 @@
1
+ from .request import RequestHandler
2
+ from .response import Response
3
+ from .config import RequestConfig
4
+ from .defaults import Defaults
5
+ from .form_data import FormData, FormDataItem
6
+ from .adapters import HTTPAdapter, MockAdapter, ProgressTracker, ProgressReader
7
+
8
+ __all__ = [
9
+ 'RequestHandler',
10
+ 'Response',
11
+ 'RequestConfig',
12
+ 'Defaults',
13
+ 'FormData',
14
+ 'FormDataItem',
15
+ 'HTTPAdapter',
16
+ 'MockAdapter',
17
+ 'ProgressTracker',
18
+ 'ProgressReader'
19
+ ]