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,38 @@
1
+ """
2
+ :mod:`etlplus.api.rate_limiting` package.
3
+
4
+ Rate-limiting configuration and runtime helpers for HTTP requests.
5
+
6
+ This package exposes small, focused primitives for configuring and enforcing
7
+ HTTP request rate limits.
8
+
9
+ Notes
10
+ -----
11
+ - :class:`RateLimitConfig` is an immutable configuration for sleep seconds
12
+ and maximum requests-per-second.
13
+ - :class:`RateLimiter` is a lightweight runtime helper that sleeps between
14
+ requests according to a resolved configuration.
15
+ - Utilities are intentionally minimal and orthogonal to the rest of the API
16
+ surface, following KISS and high cohesion/low coupling principles.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from .config import RateLimitConfig
22
+ from .config import RateLimitConfigMap
23
+ from .config import RateLimitOverrides
24
+ from .rate_limiter import RateLimiter
25
+
26
+ # SECTION: EXPORTS ========================================================== #
27
+
28
+
29
+ __all__ = [
30
+ # Classes
31
+ 'RateLimiter',
32
+ # Data Classes
33
+ 'RateLimitConfig',
34
+ # Type Aliases
35
+ 'RateLimitOverrides',
36
+ # Type Dicts
37
+ 'RateLimitConfigMap',
38
+ ]
@@ -0,0 +1,343 @@
1
+ """
2
+ :mod:`etlplus.api.rate_limiting.rate_limiter` module.
3
+
4
+ Rate limiting configuration primitives.
5
+
6
+ This module defines the lightweight, typed configuration objects used by
7
+ ``etlplus.api.rate_limiting``. The configuration layer is intentionally
8
+ separated from the runtime :class:`RateLimiter` so that higher-level
9
+ configs can depend on it without pulling in runtime behavior.
10
+
11
+ Examples
12
+ --------
13
+ Build a configuration and normalize it into a mapping::
14
+
15
+ from etlplus.api.rate_limiting import RateLimitConfig
16
+
17
+ cfg = RateLimitConfig(sleep_seconds=0.5)
18
+ as_mapping = cfg.as_mapping()
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from collections.abc import Mapping
24
+ from dataclasses import dataclass
25
+ from typing import Any
26
+ from typing import Self
27
+ from typing import TypedDict
28
+ from typing import overload
29
+
30
+ from ...mixins import BoundsWarningsMixin
31
+ from ...types import StrAnyMap
32
+ from ...utils import to_float
33
+ from ...utils import to_positive_float
34
+
35
+ # SECTION: EXPORTS ========================================================== #
36
+
37
+
38
+ __all__ = [
39
+ # Data Classes
40
+ 'RateLimitConfig',
41
+ # Type Aliases
42
+ 'RateLimitOverrides',
43
+ # Typed Dicts
44
+ 'RateLimitConfigMap',
45
+ ]
46
+
47
+
48
+ # SECTION: INTERNAL FUNCTIONS =============================================== #
49
+
50
+
51
+ def _coerce_rate_limit_map(
52
+ rate_limit: StrAnyMap | RateLimitConfig | None,
53
+ ) -> RateLimitConfigMap | None:
54
+ """
55
+ Normalize user inputs into a :class:`RateLimitConfigMap`.
56
+
57
+ This helper is the single entry point for converting loosely-typed
58
+ configuration into the canonical mapping consumed by downstream
59
+ helpers.
60
+
61
+ Parameters
62
+ ----------
63
+ rate_limit : StrAnyMap | RateLimitConfig | None
64
+ User-supplied rate-limit configuration.
65
+
66
+ Returns
67
+ -------
68
+ RateLimitConfigMap | None
69
+ Normalized mapping, or ``None`` if input couldn't be parsed.
70
+ """
71
+ if rate_limit is None:
72
+ return None
73
+ if isinstance(rate_limit, RateLimitConfig):
74
+ mapping = rate_limit.as_mapping()
75
+ return mapping or None
76
+ if isinstance(rate_limit, Mapping):
77
+ cfg = RateLimitConfig.from_obj(rate_limit)
78
+ return cfg.as_mapping() if cfg else None
79
+ return None
80
+
81
+
82
+ def _merge_rate_limit(
83
+ rate_limit: StrAnyMap | None,
84
+ overrides: RateLimitOverrides = None,
85
+ ) -> dict[str, Any]:
86
+ """
87
+ Merge ``rate_limit`` and ``overrides`` honoring override precedence.
88
+
89
+ Parameters
90
+ ----------
91
+ rate_limit : StrAnyMap | None
92
+ Base rate-limit configuration.
93
+ overrides : RateLimitOverrides, optional
94
+ Override configuration with precedence over ``rate_limit``.
95
+
96
+ Returns
97
+ -------
98
+ dict[str, Any]
99
+ Merged configuration with overrides applied.
100
+ """
101
+ merged: dict[str, Any] = {}
102
+ if rate_limit:
103
+ merged.update(rate_limit)
104
+ if overrides:
105
+ merged.update({k: v for k, v in overrides.items() if v is not None})
106
+ return merged
107
+
108
+
109
+ def _normalized_rate_values(
110
+ cfg: Mapping[str, Any] | None,
111
+ ) -> tuple[float | None, float | None]:
112
+ """
113
+ Return sanitized ``(sleep_seconds, max_per_sec)`` pair.
114
+
115
+ Parameters
116
+ ----------
117
+ cfg : Mapping[str, Any] | None
118
+ Rate-limit configuration.
119
+
120
+ Returns
121
+ -------
122
+ tuple[float | None, float | None]
123
+ Normalized ``(sleep_seconds, max_per_sec)`` values.
124
+ """
125
+ if not cfg:
126
+ return None, None
127
+ return (
128
+ to_positive_float(cfg.get('sleep_seconds')),
129
+ to_positive_float(cfg.get('max_per_sec')),
130
+ )
131
+
132
+
133
+ # SECTION: TYPED DICTS ====================================================== #
134
+
135
+
136
+ class RateLimitConfigMap(TypedDict, total=False):
137
+ """
138
+ Configuration mapping for HTTP request rate limits.
139
+
140
+ All keys are optional and intended to be mutually exclusive, positive
141
+ values.
142
+
143
+ Attributes
144
+ ----------
145
+ sleep_seconds : float, optional
146
+ Delay in seconds between requests.
147
+ max_per_sec : float, optional
148
+ Maximum requests per second.
149
+
150
+ Examples
151
+ --------
152
+ >>> rl: RateLimitConfigMap = {'max_per_sec': 4}
153
+ ... # sleep ~= 0.25s between calls
154
+ """
155
+
156
+ # -- Attributes -- #
157
+
158
+ sleep_seconds: float
159
+ max_per_sec: float
160
+
161
+
162
+ # SECTION: DATA CLASSES ===================================================== #
163
+
164
+
165
+ @dataclass(kw_only=True, slots=True)
166
+ # @dataclass(frozen=True, kw_only=True, slots=True)
167
+ class RateLimitConfig(BoundsWarningsMixin):
168
+ """
169
+ Lightweight container for optional API request rate-limit settings.
170
+
171
+ Attributes
172
+ ----------
173
+ sleep_seconds : float | None, optional
174
+ Number of seconds to sleep between requests.
175
+ max_per_sec : float | None, optional
176
+ Maximum number of requests per second.
177
+ """
178
+
179
+ # -- Attributes -- #
180
+
181
+ sleep_seconds: float | None = None
182
+ max_per_sec: float | None = None
183
+
184
+ # -- Properties -- #
185
+
186
+ @property
187
+ def enabled(self) -> bool:
188
+ """
189
+ Whether this configuration enables rate limiting.
190
+
191
+ The configuration is considered enabled when either
192
+ ``sleep_seconds`` or ``max_per_sec`` contains a positive,
193
+ numeric value after coercion.
194
+ """
195
+ sleep, per_sec = _normalized_rate_values(self.as_mapping())
196
+ return sleep is not None or per_sec is not None
197
+
198
+ # -- Instance Methods -- #
199
+
200
+ def as_mapping(self) -> RateLimitConfigMap:
201
+ """Return a normalized mapping consumable by rate-limit helpers."""
202
+ cfg: RateLimitConfigMap = {}
203
+ if (sleep := to_float(self.sleep_seconds)) is not None:
204
+ cfg['sleep_seconds'] = sleep
205
+ if (rate := to_float(self.max_per_sec)) is not None:
206
+ cfg['max_per_sec'] = rate
207
+ return cfg
208
+
209
+ def validate_bounds(self) -> list[str]:
210
+ """Return human-readable warnings for suspicious numeric bounds."""
211
+ warnings: list[str] = []
212
+ self._warn_if(
213
+ (sleep := to_float(self.sleep_seconds)) is not None and sleep < 0,
214
+ 'sleep_seconds should be >= 0',
215
+ warnings,
216
+ )
217
+ self._warn_if(
218
+ (rate := to_float(self.max_per_sec)) is not None and rate <= 0,
219
+ 'max_per_sec should be > 0',
220
+ warnings,
221
+ )
222
+ return warnings
223
+
224
+ # -- Class Methods -- #
225
+
226
+ @classmethod
227
+ def from_defaults(
228
+ cls,
229
+ obj: StrAnyMap | None,
230
+ ) -> Self | None:
231
+ """
232
+ Parse default rate-limit mapping, returning ``None`` if empty.
233
+
234
+ Only supports ``sleep_seconds`` and ``max_per_sec`` keys. Other keys
235
+ are ignored.
236
+
237
+ Parameters
238
+ ----------
239
+ obj : StrAnyMap | None
240
+ Defaults mapping (non-mapping inputs return ``None``).
241
+
242
+ Returns
243
+ -------
244
+ Self | None
245
+ Parsed instance with numeric fields coerced, or ``None`` if no
246
+ relevant keys are present or parsing failed.
247
+ """
248
+ if not isinstance(obj, Mapping):
249
+ return None
250
+
251
+ sleep_seconds = obj.get('sleep_seconds')
252
+ max_per_sec = obj.get('max_per_sec')
253
+
254
+ if sleep_seconds is None and max_per_sec is None:
255
+ return None
256
+
257
+ return cls(
258
+ sleep_seconds=to_float(sleep_seconds),
259
+ max_per_sec=to_float(max_per_sec),
260
+ )
261
+
262
+ @classmethod
263
+ def from_inputs(
264
+ cls,
265
+ *,
266
+ rate_limit: StrAnyMap | RateLimitConfig | None = None,
267
+ overrides: RateLimitOverrides = None,
268
+ ) -> Self:
269
+ """
270
+ Normalize rate-limit config and overrides into a single instance.
271
+ """
272
+ normalized = _coerce_rate_limit_map(rate_limit)
273
+ cfg = _merge_rate_limit(normalized, overrides)
274
+ sleep, max_per_sec = _normalized_rate_values(cfg)
275
+ if sleep is not None:
276
+ return cls(sleep_seconds=sleep, max_per_sec=1.0 / sleep)
277
+ if max_per_sec is not None:
278
+ delay = 1.0 / max_per_sec
279
+ return cls(sleep_seconds=delay, max_per_sec=max_per_sec)
280
+ return cls()
281
+
282
+ @classmethod
283
+ @overload
284
+ def from_obj(
285
+ cls,
286
+ obj: None,
287
+ ) -> None: ...
288
+
289
+ @classmethod
290
+ @overload
291
+ def from_obj(
292
+ cls,
293
+ obj: StrAnyMap,
294
+ ) -> Self: ...
295
+
296
+ @classmethod
297
+ @overload
298
+ def from_obj(
299
+ cls,
300
+ obj: RateLimitConfigMap,
301
+ ) -> Self: ...
302
+
303
+ @classmethod
304
+ def from_obj(
305
+ cls,
306
+ obj: StrAnyMap | RateLimitConfig | None,
307
+ ) -> Self | None:
308
+ """
309
+ Parse a mapping or existing config into a :class:`RateLimitConfig`
310
+ instance.
311
+
312
+ Parameters
313
+ ----------
314
+ obj : StrAnyMap | RateLimitConfig | None
315
+ Existing config instance or mapping with optional
316
+ rate-limit fields, or ``None``.
317
+
318
+ Returns
319
+ -------
320
+ Self | None
321
+ Parsed instance, or ``None`` if ``obj`` isn't a mapping.
322
+ """
323
+ if obj is None:
324
+ return None
325
+ if isinstance(obj, cls):
326
+ return obj
327
+ if not isinstance(obj, Mapping):
328
+ return None
329
+
330
+ return cls(
331
+ sleep_seconds=to_float(obj.get('sleep_seconds')),
332
+ max_per_sec=to_float(obj.get('max_per_sec')),
333
+ )
334
+
335
+
336
+ # SECTION: TYPE ALIASES ===================================================== #
337
+
338
+
339
+ # Common input type accepted by helpers and runtime utilities.
340
+ type RateLimitInput = StrAnyMap | RateLimitConfig | None
341
+
342
+ # Optional mapping of rate-limit fields to override values.
343
+ type RateLimitOverrides = RateLimitConfigMap | None
@@ -0,0 +1,266 @@
1
+ """
2
+ :mod:`etlplus.api.rate_limiting.rate_limiter` module.
3
+
4
+ Centralized logic for limiting HTTP request rates.
5
+
6
+ Examples
7
+ --------
8
+ Create a limiter from static configuration and apply it before each
9
+ request:
10
+
11
+ cfg = {"max_per_sec": 5}
12
+ limiter = RateLimiter.from_config(cfg)
13
+
14
+ for payload in batch:
15
+ limiter.enforce()
16
+ client.send(payload)
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import time
22
+ from dataclasses import dataclass
23
+
24
+ from ...utils import to_float
25
+ from ...utils import to_positive_float
26
+ from .config import RateLimitConfig
27
+ from .config import RateLimitConfigMap
28
+ from .config import RateLimitInput
29
+ from .config import RateLimitOverrides
30
+
31
+ # SECTION: EXPORTS ========================================================== #
32
+
33
+
34
+ __all__ = [
35
+ # Classes
36
+ 'RateLimiter',
37
+ # Data Classes
38
+ 'RateLimitConfig',
39
+ # Typed Dicts
40
+ 'RateLimitConfigMap',
41
+ ]
42
+
43
+
44
+ # SECTION: CLASSES ========================================================== #
45
+
46
+
47
+ @dataclass(slots=True, kw_only=True)
48
+ class RateLimiter:
49
+ """
50
+ HTTP request rate limit manager.
51
+
52
+ Parameters
53
+ ----------
54
+ sleep_seconds : float, optional
55
+ Fixed delay between requests, in seconds. Defaults to ``0.0``.
56
+ max_per_sec : float | None, optional
57
+ Maximum requests-per-second rate. When positive, it is converted
58
+ to a delay of ``1 / max_per_sec`` seconds between requests.
59
+ Defaults to ``None``.
60
+
61
+ Attributes
62
+ ----------
63
+ sleep_seconds : float
64
+ Effective delay between requests, in seconds.
65
+ max_per_sec : float | None
66
+ Effective maximum requests-per-second rate, or ``None`` when
67
+ rate limiting is disabled.
68
+ """
69
+
70
+ # -- Attributes -- #
71
+
72
+ sleep_seconds: float = 0.0
73
+ max_per_sec: float | None = None
74
+
75
+ # -- Magic Methods (Object Lifecycle) -- #
76
+
77
+ def __post_init__(self) -> None:
78
+ """
79
+ Normalize internal state and enforce invariants.
80
+
81
+ The two attributes ``sleep_seconds`` and ``max_per_sec`` are kept
82
+ consistent according to the following precedence:
83
+
84
+ 1. If ``sleep_seconds`` is positive, it is treated as canonical.
85
+ 2. Else if ``max_per_sec`` is positive, it is used to derive
86
+ ``sleep_seconds``.
87
+ 3. Otherwise the limiter is disabled.
88
+ """
89
+ sleep = to_positive_float(self.sleep_seconds)
90
+ rate = to_positive_float(self.max_per_sec)
91
+
92
+ if sleep is not None:
93
+ self.sleep_seconds = sleep
94
+ self.max_per_sec = 1.0 / sleep
95
+ elif rate is not None:
96
+ self.max_per_sec = rate
97
+ self.sleep_seconds = 1.0 / rate
98
+ else:
99
+ self.sleep_seconds = 0.0
100
+ self.max_per_sec = None
101
+
102
+ # -- Magic Methods (Object Representation) -- #
103
+
104
+ def __bool__(self) -> bool:
105
+ """
106
+ Return whether the limiter is enabled.
107
+
108
+ Returns
109
+ -------
110
+ bool
111
+ ``True`` if the limiter currently applies a delay, ``False``
112
+ otherwise.
113
+ """
114
+ return self.enabled
115
+
116
+ # -- Getters -- #
117
+
118
+ @property
119
+ def enabled(self) -> bool:
120
+ """
121
+ Whether this limiter currently applies any delay.
122
+
123
+ Returns
124
+ -------
125
+ bool
126
+ ``True`` if ``sleep_seconds`` is positive, ``False`` otherwise.
127
+ """
128
+ return self.sleep_seconds > 0
129
+
130
+ # -- Instance Methods -- #
131
+
132
+ def enforce(self) -> None:
133
+ """
134
+ Apply rate limiting by sleeping if configured.
135
+
136
+ Notes
137
+ -----
138
+ This method is a no-op when ``sleep_seconds`` is not positive.
139
+ """
140
+ if self.sleep_seconds > 0:
141
+ time.sleep(self.sleep_seconds)
142
+
143
+ # -- Class Methods -- #
144
+
145
+ @classmethod
146
+ def disabled(cls) -> RateLimiter:
147
+ """
148
+ Create a limiter that never sleeps.
149
+
150
+ Returns
151
+ -------
152
+ RateLimiter
153
+ Instance with rate limiting disabled.
154
+ """
155
+ return cls(sleep_seconds=0.0)
156
+
157
+ @classmethod
158
+ def fixed(
159
+ cls,
160
+ seconds: float,
161
+ ) -> RateLimiter:
162
+ """
163
+ Create a limiter with a fixed non-negative delay.
164
+
165
+ Parameters
166
+ ----------
167
+ seconds : float
168
+ Desired delay between requests, in seconds. Negative values
169
+ are treated as ``0.0``.
170
+
171
+ Returns
172
+ -------
173
+ RateLimiter
174
+ Instance with the specified delay.
175
+ """
176
+ value = to_float(seconds, 0.0, minimum=0.0) or 0.0
177
+
178
+ return cls(sleep_seconds=value)
179
+
180
+ @classmethod
181
+ def from_config(
182
+ cls,
183
+ cfg: RateLimitInput,
184
+ ) -> RateLimiter:
185
+ """
186
+ Build a :class:`RateLimiter` from a configuration mapping.
187
+
188
+ The mapping may contain the following keys:
189
+
190
+ - ``"sleep_seconds"``: positive number of seconds between requests.
191
+ - ``"max_per_sec"``: positive requests-per-second rate, converted to
192
+ a delay of ``1 / max_per_sec`` seconds between requests.
193
+
194
+ If neither key is provided or all values are invalid or non-positive,
195
+ the returned limiter has rate limiting disabled.
196
+
197
+ Parameters
198
+ ----------
199
+ cfg : RateLimitInput
200
+ Rate-limit configuration from which to derive settings.
201
+
202
+ Returns
203
+ -------
204
+ RateLimiter
205
+ Instance with normalized ``sleep_seconds`` and ``max_per_sec``.
206
+ """
207
+ config = RateLimitConfig.from_inputs(rate_limit=cfg)
208
+ if config is None:
209
+ return cls.disabled()
210
+
211
+ # RateLimiter.__post_init__ will normalize and enforce invariants.
212
+ return cls(**config.as_mapping())
213
+
214
+ @classmethod
215
+ def resolve_sleep_seconds(
216
+ cls,
217
+ *,
218
+ rate_limit: RateLimitInput,
219
+ overrides: RateLimitOverrides = None,
220
+ ) -> float:
221
+ """
222
+ Normalize the supplied mappings into a concrete delay.
223
+
224
+ Precedence is:
225
+
226
+ 1. ``overrides["sleep_seconds"]``
227
+ 2. ``overrides["max_per_sec"]``
228
+ 3. ``rate_limit["sleep_seconds"]``
229
+ 4. ``rate_limit["max_per_sec"]``
230
+
231
+ Non-numeric or non-positive values are ignored.
232
+
233
+ Parameters
234
+ ----------
235
+ rate_limit : RateLimitInput
236
+ Base rate-limit configuration. May contain ``"sleep_seconds"`` or
237
+ ``"max_per_sec"``.
238
+ overrides : RateLimitOverrides, optional
239
+ Optional overrides with the same keys as ``rate_limit``.
240
+
241
+ Returns
242
+ -------
243
+ float
244
+ Normalized delay in seconds (always >= 0).
245
+
246
+ Notes
247
+ -----
248
+ The returned value is always non-negative, even when the limiter is
249
+ disabled.
250
+
251
+ Examples
252
+ --------
253
+ >>> from etlplus.api.rate_limiting import RateLimiter
254
+ >>> RateLimiter.resolve_sleep_seconds(
255
+ ... rate_limit={'max_per_sec': 5},
256
+ ... overrides={'sleep_seconds': 0.25},
257
+ ... )
258
+ 0.25
259
+ """
260
+ config = RateLimitConfig.from_inputs(
261
+ rate_limit=rate_limit,
262
+ overrides=overrides,
263
+ )
264
+ if config is None or not config.sleep_seconds:
265
+ return 0.0
266
+ return float(config.sleep_seconds)