etlplus 0.5.4__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.
Files changed (55) hide show
  1. etlplus/__init__.py +43 -0
  2. etlplus/__main__.py +22 -0
  3. etlplus/__version__.py +14 -0
  4. etlplus/api/README.md +237 -0
  5. etlplus/api/__init__.py +136 -0
  6. etlplus/api/auth.py +432 -0
  7. etlplus/api/config.py +633 -0
  8. etlplus/api/endpoint_client.py +885 -0
  9. etlplus/api/errors.py +170 -0
  10. etlplus/api/pagination/__init__.py +47 -0
  11. etlplus/api/pagination/client.py +188 -0
  12. etlplus/api/pagination/config.py +440 -0
  13. etlplus/api/pagination/paginator.py +775 -0
  14. etlplus/api/rate_limiting/__init__.py +38 -0
  15. etlplus/api/rate_limiting/config.py +343 -0
  16. etlplus/api/rate_limiting/rate_limiter.py +266 -0
  17. etlplus/api/request_manager.py +589 -0
  18. etlplus/api/retry_manager.py +430 -0
  19. etlplus/api/transport.py +325 -0
  20. etlplus/api/types.py +172 -0
  21. etlplus/cli/__init__.py +15 -0
  22. etlplus/cli/app.py +1367 -0
  23. etlplus/cli/handlers.py +775 -0
  24. etlplus/cli/main.py +616 -0
  25. etlplus/config/__init__.py +56 -0
  26. etlplus/config/connector.py +372 -0
  27. etlplus/config/jobs.py +311 -0
  28. etlplus/config/pipeline.py +339 -0
  29. etlplus/config/profile.py +78 -0
  30. etlplus/config/types.py +204 -0
  31. etlplus/config/utils.py +120 -0
  32. etlplus/ddl.py +197 -0
  33. etlplus/enums.py +414 -0
  34. etlplus/extract.py +218 -0
  35. etlplus/file.py +657 -0
  36. etlplus/load.py +336 -0
  37. etlplus/mixins.py +62 -0
  38. etlplus/py.typed +0 -0
  39. etlplus/run.py +368 -0
  40. etlplus/run_helpers.py +843 -0
  41. etlplus/templates/__init__.py +5 -0
  42. etlplus/templates/ddl.sql.j2 +128 -0
  43. etlplus/templates/view.sql.j2 +69 -0
  44. etlplus/transform.py +1049 -0
  45. etlplus/types.py +227 -0
  46. etlplus/utils.py +638 -0
  47. etlplus/validate.py +493 -0
  48. etlplus/validation/__init__.py +44 -0
  49. etlplus/validation/utils.py +389 -0
  50. etlplus-0.5.4.dist-info/METADATA +616 -0
  51. etlplus-0.5.4.dist-info/RECORD +55 -0
  52. etlplus-0.5.4.dist-info/WHEEL +5 -0
  53. etlplus-0.5.4.dist-info/entry_points.txt +2 -0
  54. etlplus-0.5.4.dist-info/licenses/LICENSE +21 -0
  55. etlplus-0.5.4.dist-info/top_level.txt +1 -0
@@ -0,0 +1,589 @@
1
+ """
2
+ :mod:`etlplus.api.request_manager` module.
3
+
4
+ HTTP request orchestration with retries and session lifecycle control.
5
+
6
+ This module wraps ``requests`` sessions with retry-aware helpers that manage
7
+ timeouts, HTTP adapters, and context-managed session lifecycles.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Callable
13
+ from collections.abc import Sequence
14
+ from dataclasses import dataclass
15
+ from dataclasses import field
16
+ from functools import partial
17
+ from typing import Any
18
+ from typing import cast
19
+
20
+ import requests # type: ignore[import]
21
+ from requests import Response # type: ignore[import]
22
+
23
+ from ..types import JSONData
24
+ from ..types import JSONDict
25
+ from ..types import Timeout
26
+ from .errors import ApiAuthError
27
+ from .errors import ApiRequestError
28
+ from .retry_manager import RetryInput
29
+ from .retry_manager import RetryManager
30
+ from .transport import HTTPAdapterMountConfig
31
+ from .transport import build_session_with_adapters
32
+
33
+ # SECTION: TYPE ALIASES ==================================================== #
34
+
35
+
36
+ # ``requests`` accepts either a scalar timeout or a ``(connect, read)`` pair.
37
+ type TimeoutPair = tuple[Timeout, Timeout]
38
+ type TimeoutInput = Timeout | TimeoutPair
39
+
40
+
41
+ # SECTION: CONSTANTS ======================================================== #
42
+
43
+
44
+ _MISSING = object()
45
+
46
+
47
+ # SECTION: CLASSES ========================================================== #
48
+
49
+
50
+ @dataclass(slots=True)
51
+ class RequestManager:
52
+ """
53
+ Encapsulate HTTP dispatch, retries, and session lifecycle.
54
+
55
+ Parameters
56
+ ----------
57
+ retry : RetryInput, optional
58
+ Retry policy to apply to requests. Default is ``None``.
59
+ retry_network_errors : bool, optional
60
+ Whether to retry on network errors. Default is ``False``.
61
+ default_timeout : TimeoutInput, optional
62
+ Default timeout for requests (seconds or ``(connect, read)`` tuple).
63
+ session : requests.Session | None, optional
64
+ Optional pre-configured session to use. Default is ``None``.
65
+ session_factory : Callable[[], requests.Session] | None, optional
66
+ Optional factory for lazily creating sessions when ``session`` is
67
+ ``None``.
68
+ retry_cap : float, optional
69
+ Maximum backoff cap in seconds. Default is 30.0.
70
+ session_adapters : Sequence[HTTPAdapterMountConfig] | None, optional
71
+ Adapter mount configurations used when lazily building a session via
72
+ :func:`etlplus.api.transport.build_session_with_adapters`.
73
+
74
+ Attributes
75
+ ----------
76
+ retry : RetryInput
77
+ Retry policy to apply to requests.
78
+ retry_network_errors : bool
79
+ Whether to retry on network errors.
80
+ default_timeout : TimeoutInput
81
+ Default timeout for requests (seconds or ``(connect, read)`` tuple).
82
+ session : requests.Session | None
83
+ Optional pre-configured session to use.
84
+ session_factory : Callable[[], requests.Session] | None
85
+ Optional factory for creating sessions.
86
+ retry_cap : float
87
+ Maximum backoff cap in seconds for :class:`RetryManager` sleeps.
88
+ session_adapters : Sequence[HTTPAdapterMountConfig] | None
89
+ Adapter mount configurations used when lazily building a session.
90
+ """
91
+
92
+ # -- Attributes -- #
93
+
94
+ retry: RetryInput = None
95
+ retry_network_errors: bool = False
96
+ default_timeout: TimeoutInput = 10.0
97
+ session: requests.Session | None = None
98
+ session_factory: Callable[[], requests.Session] | None = None
99
+ retry_cap: float = 30.0
100
+ session_adapters: Sequence[HTTPAdapterMountConfig] | None = None
101
+
102
+ def __post_init__(self) -> None:
103
+ if self.session_adapters:
104
+ self.session_adapters = tuple(self.session_adapters)
105
+
106
+ # -- Internal Attributes -- #
107
+
108
+ _ctx_session: Any | None = field(default=None, init=False, repr=False)
109
+ _ctx_owns_session: bool = field(default=False, init=False, repr=False)
110
+
111
+ # -- Magic Methods (Context Manager Protocol) -- #
112
+
113
+ def __enter__(self) -> RequestManager:
114
+ """
115
+ Enter the runtime context and ensure a session is available.
116
+
117
+ Returns
118
+ -------
119
+ RequestManager
120
+ The manager instance with an active session context.
121
+ """
122
+ if self._ctx_session is not None:
123
+ return self
124
+ if self.session is not None:
125
+ self._ctx_session = self.session
126
+ self._ctx_owns_session = False
127
+ return self
128
+ sess, owns_session = self._instantiate_session()
129
+ if sess is None:
130
+ sess = requests.Session()
131
+ owns_session = True
132
+ self._ctx_session = sess
133
+ self._ctx_owns_session = owns_session
134
+ return self
135
+
136
+ def __exit__(
137
+ self,
138
+ exc_type: type[BaseException] | None,
139
+ exc: BaseException | None,
140
+ tb: Any,
141
+ ) -> None:
142
+ """
143
+ Exit the runtime context and close owned sessions.
144
+
145
+ Parameters
146
+ ----------
147
+ exc_type : type[BaseException] | None
148
+ Exception type if raised, else ``None``.
149
+ exc : BaseException | None
150
+ Exception instance if raised, else ``None``.
151
+ tb : Any
152
+ Traceback if an exception was raised, else ``None``.
153
+ """
154
+ if self._ctx_session is None:
155
+ return
156
+ if self._ctx_owns_session:
157
+ try:
158
+ self._ctx_session.close()
159
+ except AttributeError:
160
+ pass
161
+ self._ctx_session = None
162
+ self._ctx_owns_session = False
163
+
164
+ # -- Instance Methods -- #
165
+
166
+ def get(
167
+ self,
168
+ url: str,
169
+ request_callable: Callable[..., JSONData] | None = None,
170
+ **kwargs: Any,
171
+ ) -> JSONData:
172
+ """
173
+ Perform a GET request.
174
+
175
+ Parameters
176
+ ----------
177
+ url : str
178
+ Target URL.
179
+ request_callable : Callable[..., JSONData] | None, optional
180
+ Optional callable compatible with ``requests.Session.request``.
181
+ **kwargs : Any
182
+ Additional keyword arguments for the request.
183
+
184
+ Returns
185
+ -------
186
+ JSONData
187
+ Parsed JSON response data.
188
+ """
189
+ return self.request(
190
+ 'GET',
191
+ url,
192
+ request_callable=request_callable,
193
+ **kwargs,
194
+ )
195
+
196
+ def post(
197
+ self,
198
+ url: str,
199
+ request_callable: Callable[..., JSONData] | None = None,
200
+ **kwargs: Any,
201
+ ) -> JSONData:
202
+ """
203
+ Perform a POST request.
204
+
205
+ Parameters
206
+ ----------
207
+ url : str
208
+ Target URL.
209
+ request_callable : Callable[..., JSONData] | None, optional
210
+ Optional callable compatible with ``requests.Session.request``.
211
+ **kwargs : Any
212
+ Additional keyword arguments for the request.
213
+
214
+ Returns
215
+ -------
216
+ JSONData
217
+ Parsed JSON response data.
218
+ """
219
+ return self.request(
220
+ 'POST',
221
+ url,
222
+ request_callable=request_callable,
223
+ **kwargs,
224
+ )
225
+
226
+ def request(
227
+ self,
228
+ method: str,
229
+ url: str,
230
+ *,
231
+ request_callable: Callable[..., JSONData] | None = None,
232
+ **kw: Any,
233
+ ) -> JSONData:
234
+ """
235
+ Perform a request with retries.
236
+
237
+ Parameters
238
+ ----------
239
+ method : str
240
+ HTTP method (e.g., 'GET', 'POST').
241
+ url : str
242
+ Target URL.
243
+ request_callable : Callable[..., JSONData] | None, optional
244
+ Optional callable compatible with ``requests.Session.request``.
245
+ **kw : Any
246
+ Additional keyword arguments for the request.
247
+
248
+ Returns
249
+ -------
250
+ JSONData
251
+ Parsed JSON response data.
252
+
253
+ Raises
254
+ ------
255
+ ApiAuthError
256
+ If authentication fails (HTTP 401 or 403) and retries are
257
+ exhausted.
258
+ ApiRequestError
259
+ If the request ultimately fails for a non-authentication reason.
260
+ """
261
+ method_normalized = self._normalize_http_method(method)
262
+
263
+ call_kwargs = dict(kw)
264
+ supplied_timeout = call_kwargs.pop('timeout', _MISSING)
265
+ timeout = self._resolve_timeout(supplied_timeout)
266
+ user_session = call_kwargs.pop('session', None)
267
+ session, owns_session = self._resolve_session_for_call(user_session)
268
+ fetch = partial(
269
+ self.request_once,
270
+ method_normalized,
271
+ session=session,
272
+ timeout=timeout,
273
+ request_callable=request_callable,
274
+ )
275
+
276
+ try:
277
+ policy = self.retry
278
+ if not policy:
279
+ try:
280
+ return fetch(url, **call_kwargs)
281
+ except requests.RequestException as exc: # pragma: no cover
282
+ status = getattr(
283
+ getattr(exc, 'response', None),
284
+ 'status_code',
285
+ None,
286
+ )
287
+ if status in {401, 403}:
288
+ raise ApiAuthError(
289
+ url=url,
290
+ status=status,
291
+ attempts=1,
292
+ retried=False,
293
+ retry_policy=None,
294
+ cause=exc,
295
+ ) from exc
296
+ raise ApiRequestError(
297
+ url=url,
298
+ status=status,
299
+ attempts=1,
300
+ retried=False,
301
+ retry_policy=None,
302
+ cause=exc,
303
+ ) from exc
304
+
305
+ retry_mgr = RetryManager(
306
+ policy=policy,
307
+ retry_network_errors=self.retry_network_errors,
308
+ cap=self.retry_cap,
309
+ )
310
+ return retry_mgr.run_with_retry(fetch, url, **call_kwargs)
311
+ finally:
312
+ if owns_session and session is not None:
313
+ try:
314
+ session.close()
315
+ except AttributeError: # pragma: no cover - defensive
316
+ pass
317
+
318
+ def request_once(
319
+ self,
320
+ method: str,
321
+ url: str,
322
+ *,
323
+ session: requests.Session | None,
324
+ timeout: TimeoutInput,
325
+ request_callable: Callable[..., JSONData] | None = None,
326
+ **kwargs: Any,
327
+ ) -> JSONData:
328
+ """
329
+ Perform a single request without retries.
330
+
331
+ Parameters
332
+ ----------
333
+ method : str
334
+ HTTP method (e.g., 'GET', 'POST').
335
+ url : str
336
+ Target URL.
337
+ session : requests.Session | None
338
+ Optional HTTP session to use.
339
+ timeout : TimeoutInput
340
+ Timeout for the request (seconds or ``(connect, read)`` tuple).
341
+ request_callable : Callable[..., JSONData] | None, optional
342
+ Optional custom request function.
343
+ **kwargs : Any
344
+ Additional keyword arguments for the request.
345
+
346
+ Returns
347
+ -------
348
+ JSONData
349
+ Parsed JSON response data.
350
+ """
351
+ method_normalized = self._normalize_http_method(method)
352
+ if request_callable is not None:
353
+ return request_callable(
354
+ method_normalized,
355
+ url,
356
+ session=session,
357
+ timeout=timeout,
358
+ **kwargs,
359
+ )
360
+ response = self._send_http_request(
361
+ method_normalized,
362
+ url,
363
+ session=session,
364
+ timeout=timeout,
365
+ **kwargs,
366
+ )
367
+ response.raise_for_status()
368
+ return self._parse_response_payload(response)
369
+
370
+ # -- Internal Instance Methods -- #
371
+
372
+ def _build_adapter_session(self) -> requests.Session | None:
373
+ """
374
+ Build a session configured with HTTP adapters when provided.
375
+
376
+ Returns
377
+ -------
378
+ requests.Session | None
379
+ Configured session with HTTP adapters, or ``None``.
380
+ """
381
+ if not self.session_adapters:
382
+ return None
383
+ try:
384
+ return build_session_with_adapters(tuple(self.session_adapters))
385
+ except (ValueError, TypeError, AttributeError):
386
+ return requests.Session()
387
+
388
+ def _instantiate_session(self) -> tuple[requests.Session | None, bool]:
389
+ """
390
+ Create a session from factory/adapters when available.
391
+
392
+ Returns
393
+ -------
394
+ tuple[requests.Session | None, bool]
395
+ Pair of ``(session, owns_session)`` where ``owns_session``
396
+ indicates whether this manager is responsible for closing the
397
+ session after the request completes.
398
+ """
399
+ if self.session_factory is not None:
400
+ try:
401
+ session = self.session_factory()
402
+ except (RuntimeError, TypeError, ValueError): # pragma: no cover
403
+ return None, False
404
+ if session is not None:
405
+ return session, True
406
+ return None, False
407
+ adapter_session = self._build_adapter_session()
408
+ if adapter_session is not None:
409
+ return adapter_session, True
410
+ return None, False
411
+
412
+ def _parse_response_payload(
413
+ self,
414
+ response: Response,
415
+ ) -> JSONData:
416
+ """
417
+ Parse the response payload into JSONData.
418
+
419
+ Parameters
420
+ ----------
421
+ response : Response
422
+ The HTTP response object.
423
+
424
+ Returns
425
+ -------
426
+ JSONData
427
+ Parsed JSON response data.
428
+ """
429
+ content_type = response.headers.get('content-type', '').lower()
430
+ if 'application/json' in content_type:
431
+ try:
432
+ payload: Any = response.json()
433
+ except ValueError:
434
+ return {
435
+ 'content': response.text,
436
+ 'content_type': content_type,
437
+ }
438
+ if isinstance(payload, dict):
439
+ return cast(JSONDict, payload)
440
+ if isinstance(payload, list):
441
+ if all(isinstance(item, dict) for item in payload):
442
+ return cast(JSONData, payload)
443
+ return [{'value': item} for item in payload]
444
+ return {'value': payload}
445
+ return {
446
+ 'content': response.text,
447
+ 'content_type': content_type,
448
+ }
449
+
450
+ def _resolve_request_callable(
451
+ self,
452
+ session: requests.Session | None,
453
+ ) -> Callable[..., Response]:
454
+ """
455
+ Resolve the request callable from the given session or default to
456
+ :func:`requests.request`.
457
+
458
+ Parameters
459
+ ----------
460
+ session : requests.Session | None
461
+ Optional session object to use for the request.
462
+
463
+ Returns
464
+ -------
465
+ Callable[..., Response]
466
+ Callable to perform the HTTP request.
467
+
468
+ Raises
469
+ ------
470
+ TypeError
471
+ If the provided session does not have a callable 'request' method.
472
+ """
473
+ if session is not None:
474
+ request_callable = getattr(session, 'request', None)
475
+ if callable(request_callable):
476
+ return request_callable
477
+ raise TypeError('Session must expose a callable "request" method')
478
+ return requests.request
479
+
480
+ def _resolve_timeout(
481
+ self,
482
+ timeout: TimeoutInput | object,
483
+ ) -> TimeoutInput:
484
+ """
485
+ Resolve the timeout value, defaulting to the instance's
486
+ ``default_timeout`` if not provided.
487
+
488
+ Parameters
489
+ ----------
490
+ timeout : TimeoutInput | object
491
+ Supplied timeout (seconds or ``(connect, read)`` tuple) or
492
+ sentinel.
493
+
494
+ Returns
495
+ -------
496
+ TimeoutInput
497
+ Resolved timeout value (seconds or ``(connect, read)`` tuple).
498
+ """
499
+ if timeout is _MISSING:
500
+ return cast(TimeoutInput, self.default_timeout)
501
+ return cast(TimeoutInput, timeout)
502
+
503
+ def _resolve_session_for_call(
504
+ self,
505
+ explicit: requests.Session | None,
506
+ ) -> tuple[requests.Session | None, bool]:
507
+ """
508
+ Determine which session should service the current request.
509
+
510
+ Parameters
511
+ ----------
512
+ explicit : requests.Session | None
513
+ Session provided directly by the caller.
514
+
515
+ Returns
516
+ -------
517
+ tuple[requests.Session | None, bool]
518
+ Pair of ``(session, owns_session)`` where ``owns_session``
519
+ indicates whether this manager is responsible for closing the
520
+ session after the request completes.
521
+ """
522
+ if explicit is not None:
523
+ return explicit, False
524
+ if self._ctx_session is not None:
525
+ return self._ctx_session, False
526
+ if self.session is not None:
527
+ return self.session, False
528
+ session, owns_session = self._instantiate_session()
529
+ if session is not None:
530
+ return session, owns_session
531
+ return None, False
532
+
533
+ def _send_http_request(
534
+ self,
535
+ method: str,
536
+ url: str,
537
+ *,
538
+ session: requests.Session | None,
539
+ timeout: TimeoutInput,
540
+ **kwargs: Any,
541
+ ) -> Response:
542
+ """
543
+ Send the actual HTTP request using the specified method and URL.
544
+
545
+ Parameters
546
+ ----------
547
+ method : str
548
+ HTTP method to use for the request.
549
+ url : str
550
+ Target URL for the request.
551
+ session : requests.Session | None
552
+ Optional session object to use for the request.
553
+ timeout : TimeoutInput
554
+ Timeout value (seconds or ``(connect, read)`` tuple).
555
+ **kwargs : Any
556
+ Additional keyword arguments for the request.
557
+
558
+ Returns
559
+ -------
560
+ Response
561
+ The HTTP response object.
562
+ """
563
+ call_kwargs = {**kwargs, 'timeout': timeout}
564
+ method_normalized = self._normalize_http_method(method)
565
+ request_callable = self._resolve_request_callable(session)
566
+ return request_callable(method_normalized, url, **call_kwargs)
567
+
568
+ # -- Internal Static Methods -- #
569
+
570
+ @staticmethod
571
+ def _normalize_http_method(
572
+ method: str | None,
573
+ ) -> str:
574
+ """
575
+ Normalize the HTTP method to uppercase, defaulting to 'GET' if not
576
+ provided.
577
+
578
+ Parameters
579
+ ----------
580
+ method : str | None
581
+ HTTP method to normalize.
582
+
583
+ Returns
584
+ -------
585
+ str
586
+ Normalized HTTP method.
587
+ """
588
+ candidate = (method or '').strip().upper()
589
+ return candidate or 'GET'