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.
- etlplus/__init__.py +43 -0
- etlplus/__main__.py +22 -0
- etlplus/__version__.py +14 -0
- etlplus/api/README.md +237 -0
- etlplus/api/__init__.py +136 -0
- etlplus/api/auth.py +432 -0
- etlplus/api/config.py +633 -0
- etlplus/api/endpoint_client.py +885 -0
- etlplus/api/errors.py +170 -0
- etlplus/api/pagination/__init__.py +47 -0
- etlplus/api/pagination/client.py +188 -0
- etlplus/api/pagination/config.py +440 -0
- etlplus/api/pagination/paginator.py +775 -0
- etlplus/api/rate_limiting/__init__.py +38 -0
- etlplus/api/rate_limiting/config.py +343 -0
- etlplus/api/rate_limiting/rate_limiter.py +266 -0
- etlplus/api/request_manager.py +589 -0
- etlplus/api/retry_manager.py +430 -0
- etlplus/api/transport.py +325 -0
- etlplus/api/types.py +172 -0
- etlplus/cli/__init__.py +15 -0
- etlplus/cli/app.py +1367 -0
- etlplus/cli/handlers.py +775 -0
- etlplus/cli/main.py +616 -0
- etlplus/config/__init__.py +56 -0
- etlplus/config/connector.py +372 -0
- etlplus/config/jobs.py +311 -0
- etlplus/config/pipeline.py +339 -0
- etlplus/config/profile.py +78 -0
- etlplus/config/types.py +204 -0
- etlplus/config/utils.py +120 -0
- etlplus/ddl.py +197 -0
- etlplus/enums.py +414 -0
- etlplus/extract.py +218 -0
- etlplus/file.py +657 -0
- etlplus/load.py +336 -0
- etlplus/mixins.py +62 -0
- etlplus/py.typed +0 -0
- etlplus/run.py +368 -0
- etlplus/run_helpers.py +843 -0
- etlplus/templates/__init__.py +5 -0
- etlplus/templates/ddl.sql.j2 +128 -0
- etlplus/templates/view.sql.j2 +69 -0
- etlplus/transform.py +1049 -0
- etlplus/types.py +227 -0
- etlplus/utils.py +638 -0
- etlplus/validate.py +493 -0
- etlplus/validation/__init__.py +44 -0
- etlplus/validation/utils.py +389 -0
- etlplus-0.5.4.dist-info/METADATA +616 -0
- etlplus-0.5.4.dist-info/RECORD +55 -0
- etlplus-0.5.4.dist-info/WHEEL +5 -0
- etlplus-0.5.4.dist-info/entry_points.txt +2 -0
- etlplus-0.5.4.dist-info/licenses/LICENSE +21 -0
- 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)
|