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
etlplus/api/config.py
ADDED
|
@@ -0,0 +1,633 @@
|
|
|
1
|
+
"""
|
|
2
|
+
:mod:`etlplus.api.config` module.
|
|
3
|
+
|
|
4
|
+
Configuration dataclasses for REST API services, profiles, and endpoints.
|
|
5
|
+
|
|
6
|
+
These models used to live under :mod:`etlplus.config`, but they belong in the
|
|
7
|
+
API layer because they compose runtime types such as
|
|
8
|
+
:class:`etlplus.api.EndpointClient`, :class:`etlplus.api.PaginationConfig`, and
|
|
9
|
+
:class:`etlplus.api.RateLimitConfig`.
|
|
10
|
+
|
|
11
|
+
Notes
|
|
12
|
+
-----
|
|
13
|
+
- TypedDict references remain editor hints only; :meth:`from_obj` accepts
|
|
14
|
+
``StrAnyMap`` for permissive parsing.
|
|
15
|
+
- Helper functions near the bottom keep parsing logic centralized and avoid
|
|
16
|
+
leaking implementation details.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from collections.abc import Mapping
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from dataclasses import field
|
|
24
|
+
from types import MappingProxyType
|
|
25
|
+
from typing import TYPE_CHECKING
|
|
26
|
+
from typing import Any
|
|
27
|
+
from typing import Self
|
|
28
|
+
from typing import overload
|
|
29
|
+
from urllib.parse import urlsplit
|
|
30
|
+
from urllib.parse import urlunsplit
|
|
31
|
+
|
|
32
|
+
from ..enums import HttpMethod
|
|
33
|
+
from ..types import StrAnyMap
|
|
34
|
+
from ..types import StrStrMap
|
|
35
|
+
from ..utils import cast_str_dict
|
|
36
|
+
from ..utils import coerce_dict
|
|
37
|
+
from ..utils import maybe_mapping
|
|
38
|
+
from .endpoint_client import EndpointClient
|
|
39
|
+
from .pagination import PaginationConfig
|
|
40
|
+
from .rate_limiting import RateLimitConfig
|
|
41
|
+
|
|
42
|
+
if TYPE_CHECKING:
|
|
43
|
+
from ..config.types import ApiConfigMap
|
|
44
|
+
from ..config.types import ApiProfileConfigMap
|
|
45
|
+
from ..config.types import EndpointMap
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# SECTION: EXPORTS ========================================================== #
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
__all__ = [
|
|
52
|
+
# Data Classes
|
|
53
|
+
'ApiConfig',
|
|
54
|
+
'ApiProfileConfig',
|
|
55
|
+
'EndpointConfig',
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# SECTION: INTERNAL CONSTANTS =============================================== #
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
_HTTP_METHODS: tuple[str, ...] = tuple(member.name for member in HttpMethod)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# SECTION: INTERNAL FUNCTIONS =============================================== #
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _effective_service_defaults(
|
|
69
|
+
*,
|
|
70
|
+
profiles: Mapping[str, ApiProfileConfig],
|
|
71
|
+
fallback_base: Any,
|
|
72
|
+
fallback_headers: dict[str, str],
|
|
73
|
+
) -> tuple[str, dict[str, str]]:
|
|
74
|
+
"""
|
|
75
|
+
Return ``(base_url, headers)`` using ``profiles`` when present.
|
|
76
|
+
|
|
77
|
+
Parameters
|
|
78
|
+
----------
|
|
79
|
+
profiles : Mapping[str, ApiProfileConfig]
|
|
80
|
+
Named profile configurations.
|
|
81
|
+
fallback_base : Any
|
|
82
|
+
Top-level base URL when no profiles are defined.
|
|
83
|
+
fallback_headers : dict[str, str]
|
|
84
|
+
Top-level headers when no profiles are defined.
|
|
85
|
+
|
|
86
|
+
Returns
|
|
87
|
+
-------
|
|
88
|
+
tuple[str, dict[str, str]]
|
|
89
|
+
Effective ``(base_url, headers)`` pair.
|
|
90
|
+
|
|
91
|
+
Raises
|
|
92
|
+
------
|
|
93
|
+
TypeError
|
|
94
|
+
If no profiles are defined and ``fallback_base`` is not a string.
|
|
95
|
+
"""
|
|
96
|
+
if profiles:
|
|
97
|
+
name = 'default' if 'default' in profiles else next(iter(profiles))
|
|
98
|
+
selected = profiles[name]
|
|
99
|
+
headers = dict(selected.headers)
|
|
100
|
+
if fallback_headers:
|
|
101
|
+
headers |= fallback_headers
|
|
102
|
+
return selected.base_url, headers
|
|
103
|
+
|
|
104
|
+
if not isinstance(fallback_base, str):
|
|
105
|
+
raise TypeError('ApiConfig requires "base_url" (str)')
|
|
106
|
+
return fallback_base, fallback_headers
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _normalize_method(
|
|
110
|
+
value: Any,
|
|
111
|
+
) -> Any | None:
|
|
112
|
+
"""
|
|
113
|
+
Return a validated HTTP method string or pass through custom inputs.
|
|
114
|
+
|
|
115
|
+
Parameters
|
|
116
|
+
----------
|
|
117
|
+
value : Any
|
|
118
|
+
Raw method value.
|
|
119
|
+
|
|
120
|
+
Returns
|
|
121
|
+
-------
|
|
122
|
+
Any | None
|
|
123
|
+
Normalized method string, ``None``, or original input.
|
|
124
|
+
|
|
125
|
+
Raises
|
|
126
|
+
------
|
|
127
|
+
ValueError
|
|
128
|
+
If the string value is not a supported HTTP method.
|
|
129
|
+
"""
|
|
130
|
+
if value is None:
|
|
131
|
+
return None
|
|
132
|
+
if isinstance(value, HttpMethod):
|
|
133
|
+
return value.name
|
|
134
|
+
if isinstance(value, str):
|
|
135
|
+
normalized = value.strip().upper()
|
|
136
|
+
if not normalized:
|
|
137
|
+
return None
|
|
138
|
+
if normalized not in _HTTP_METHODS:
|
|
139
|
+
raise ValueError(
|
|
140
|
+
f'Unsupported HTTP method {normalized!r}; '
|
|
141
|
+
f'must be one of {_HTTP_METHODS}',
|
|
142
|
+
)
|
|
143
|
+
return normalized
|
|
144
|
+
return value
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _parse_endpoints(
|
|
148
|
+
raw: Any,
|
|
149
|
+
) -> dict[str, EndpointConfig]:
|
|
150
|
+
"""
|
|
151
|
+
Return parsed endpoint configs keyed by name.
|
|
152
|
+
|
|
153
|
+
Parameters
|
|
154
|
+
----------
|
|
155
|
+
raw : Any
|
|
156
|
+
Raw endpoint mapping.
|
|
157
|
+
|
|
158
|
+
Returns
|
|
159
|
+
-------
|
|
160
|
+
dict[str, EndpointConfig]
|
|
161
|
+
Parsed endpoint configurations.
|
|
162
|
+
"""
|
|
163
|
+
if not (mapping := maybe_mapping(raw)):
|
|
164
|
+
return {}
|
|
165
|
+
return {
|
|
166
|
+
str(name): EndpointConfig.from_obj(data)
|
|
167
|
+
for name, data in mapping.items()
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _parse_profiles(raw: Any) -> dict[str, ApiProfileConfig]:
|
|
172
|
+
"""
|
|
173
|
+
Return parsed API profiles keyed by name.
|
|
174
|
+
|
|
175
|
+
Parameters
|
|
176
|
+
----------
|
|
177
|
+
raw : Any
|
|
178
|
+
Raw profiles mapping.
|
|
179
|
+
|
|
180
|
+
Returns
|
|
181
|
+
-------
|
|
182
|
+
dict[str, ApiProfileConfig]
|
|
183
|
+
Parsed API profile configurations.
|
|
184
|
+
"""
|
|
185
|
+
if not (mapping := maybe_mapping(raw)):
|
|
186
|
+
return {}
|
|
187
|
+
parsed: dict[str, ApiProfileConfig] = {}
|
|
188
|
+
for name, profile_raw in mapping.items():
|
|
189
|
+
if not (profile_map := maybe_mapping(profile_raw)):
|
|
190
|
+
continue
|
|
191
|
+
parsed[str(name)] = ApiProfileConfig.from_obj(profile_map)
|
|
192
|
+
return parsed
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# SECTION: DATA CLASSES ===================================================== #
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@dataclass(slots=True, kw_only=True)
|
|
199
|
+
class ApiProfileConfig:
|
|
200
|
+
"""
|
|
201
|
+
Profile configuration for a REST API service.
|
|
202
|
+
|
|
203
|
+
Attributes
|
|
204
|
+
----------
|
|
205
|
+
base_url : str
|
|
206
|
+
Base URL for the API.
|
|
207
|
+
headers : StrStrMap
|
|
208
|
+
Profile-level default headers (merged with defaults.headers).
|
|
209
|
+
base_path : str | None
|
|
210
|
+
Optional base path prefixed to endpoint paths when composing URLs.
|
|
211
|
+
auth : StrAnyMap
|
|
212
|
+
Optional auth block (provider-specific shape, passed through).
|
|
213
|
+
pagination_defaults : PaginationConfig | None
|
|
214
|
+
Optional pagination defaults applied to endpoints referencing this
|
|
215
|
+
profile (lowest precedence).
|
|
216
|
+
rate_limit_defaults : RateLimitConfig | None
|
|
217
|
+
Optional rate limit defaults applied to endpoints referencing this
|
|
218
|
+
profile (lowest precedence).
|
|
219
|
+
"""
|
|
220
|
+
|
|
221
|
+
# -- Attributes -- #
|
|
222
|
+
|
|
223
|
+
base_url: str
|
|
224
|
+
headers: StrStrMap = field(default_factory=dict)
|
|
225
|
+
base_path: str | None = None
|
|
226
|
+
auth: StrAnyMap = field(default_factory=dict)
|
|
227
|
+
|
|
228
|
+
# Optional defaults carried at profile level
|
|
229
|
+
pagination_defaults: PaginationConfig | None = None
|
|
230
|
+
rate_limit_defaults: RateLimitConfig | None = None
|
|
231
|
+
|
|
232
|
+
# -- Magic Methods (Object Lifecycle) -- #
|
|
233
|
+
|
|
234
|
+
def __post_init__(self) -> None:
|
|
235
|
+
object.__setattr__(
|
|
236
|
+
self,
|
|
237
|
+
'headers',
|
|
238
|
+
MappingProxyType(dict(self.headers)),
|
|
239
|
+
)
|
|
240
|
+
object.__setattr__(
|
|
241
|
+
self,
|
|
242
|
+
'auth',
|
|
243
|
+
MappingProxyType(dict(self.auth)),
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
# -- Class Methods -- #
|
|
247
|
+
|
|
248
|
+
@classmethod
|
|
249
|
+
@overload
|
|
250
|
+
def from_obj(
|
|
251
|
+
cls,
|
|
252
|
+
obj: ApiProfileConfigMap,
|
|
253
|
+
) -> Self: ...
|
|
254
|
+
|
|
255
|
+
@classmethod
|
|
256
|
+
@overload
|
|
257
|
+
def from_obj(
|
|
258
|
+
cls,
|
|
259
|
+
obj: StrAnyMap,
|
|
260
|
+
) -> Self: ...
|
|
261
|
+
|
|
262
|
+
@classmethod
|
|
263
|
+
def from_obj(
|
|
264
|
+
cls,
|
|
265
|
+
obj: StrAnyMap,
|
|
266
|
+
) -> Self:
|
|
267
|
+
"""
|
|
268
|
+
Parse a mapping into an :class:`ApiProfileConfig` instance.
|
|
269
|
+
|
|
270
|
+
Parameters
|
|
271
|
+
----------
|
|
272
|
+
obj : StrAnyMap
|
|
273
|
+
Raw profile configuration.
|
|
274
|
+
|
|
275
|
+
Returns
|
|
276
|
+
-------
|
|
277
|
+
Self
|
|
278
|
+
Parsed profile configuration.
|
|
279
|
+
|
|
280
|
+
Raises
|
|
281
|
+
------
|
|
282
|
+
TypeError
|
|
283
|
+
If required fields are missing or of incorrect type.
|
|
284
|
+
"""
|
|
285
|
+
if not isinstance(obj, Mapping):
|
|
286
|
+
raise TypeError('ApiProfileConfig must be a mapping')
|
|
287
|
+
|
|
288
|
+
if not isinstance((base := obj.get('base_url')), str):
|
|
289
|
+
raise TypeError('ApiProfileConfig requires "base_url" (str)')
|
|
290
|
+
|
|
291
|
+
defaults_raw = coerce_dict(obj.get('defaults'))
|
|
292
|
+
merged_headers = cast_str_dict(
|
|
293
|
+
defaults_raw.get('headers'),
|
|
294
|
+
) | cast_str_dict(obj.get('headers'))
|
|
295
|
+
|
|
296
|
+
base_path = obj.get('base_path')
|
|
297
|
+
auth = coerce_dict(obj.get('auth'))
|
|
298
|
+
|
|
299
|
+
pag_def = PaginationConfig.from_defaults(
|
|
300
|
+
defaults_raw.get('pagination'),
|
|
301
|
+
)
|
|
302
|
+
rl_def = RateLimitConfig.from_defaults(defaults_raw.get('rate_limit'))
|
|
303
|
+
|
|
304
|
+
return cls(
|
|
305
|
+
base_url=base,
|
|
306
|
+
headers=merged_headers,
|
|
307
|
+
base_path=base_path,
|
|
308
|
+
auth=auth,
|
|
309
|
+
pagination_defaults=pag_def,
|
|
310
|
+
rate_limit_defaults=rl_def,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
@dataclass(slots=True, kw_only=True)
|
|
315
|
+
class ApiConfig:
|
|
316
|
+
"""
|
|
317
|
+
Configuration for a REST API service.
|
|
318
|
+
|
|
319
|
+
Attributes
|
|
320
|
+
----------
|
|
321
|
+
base_url : str
|
|
322
|
+
Effective base URL (derived from profiles or top-level input).
|
|
323
|
+
headers : StrStrMap
|
|
324
|
+
Effective headers (profile + top-level merged with precedence).
|
|
325
|
+
endpoints : Mapping[str, EndpointConfig]
|
|
326
|
+
Endpoint configurations keyed by name.
|
|
327
|
+
profiles : Mapping[str, ApiProfileConfig]
|
|
328
|
+
Named profile configurations; first or ``default`` becomes active.
|
|
329
|
+
"""
|
|
330
|
+
|
|
331
|
+
# -- Attributes -- #
|
|
332
|
+
|
|
333
|
+
base_url: str
|
|
334
|
+
headers: StrStrMap = field(default_factory=dict)
|
|
335
|
+
endpoints: Mapping[str, EndpointConfig] = field(default_factory=dict)
|
|
336
|
+
|
|
337
|
+
# See also: ApiProfileConfig.from_obj for profile parsing logic.
|
|
338
|
+
profiles: Mapping[str, ApiProfileConfig] = field(default_factory=dict)
|
|
339
|
+
|
|
340
|
+
# -- Magic Methods (Object Lifecycle) -- #
|
|
341
|
+
|
|
342
|
+
def __post_init__(self) -> None:
|
|
343
|
+
object.__setattr__(
|
|
344
|
+
self,
|
|
345
|
+
'headers',
|
|
346
|
+
MappingProxyType(dict(self.headers)),
|
|
347
|
+
)
|
|
348
|
+
object.__setattr__(
|
|
349
|
+
self,
|
|
350
|
+
'endpoints',
|
|
351
|
+
MappingProxyType({str(k): v for k, v in self.endpoints.items()}),
|
|
352
|
+
)
|
|
353
|
+
object.__setattr__(
|
|
354
|
+
self,
|
|
355
|
+
'profiles',
|
|
356
|
+
MappingProxyType({str(k): v for k, v in self.profiles.items()}),
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
# -- Internal Instance Methods -- #
|
|
360
|
+
|
|
361
|
+
def _selected_profile(self) -> ApiProfileConfig | None:
|
|
362
|
+
"""
|
|
363
|
+
Return the active profile object (``default`` preferred) or ``None``.
|
|
364
|
+
"""
|
|
365
|
+
if not (profiles := self.profiles):
|
|
366
|
+
return None
|
|
367
|
+
|
|
368
|
+
name = 'default' if 'default' in profiles else next(iter(profiles))
|
|
369
|
+
|
|
370
|
+
return profiles.get(name)
|
|
371
|
+
|
|
372
|
+
def _profile_attr(
|
|
373
|
+
self,
|
|
374
|
+
attr: str,
|
|
375
|
+
) -> Any:
|
|
376
|
+
"""
|
|
377
|
+
Return an attribute on the selected profile, if available.
|
|
378
|
+
|
|
379
|
+
Parameters
|
|
380
|
+
----------
|
|
381
|
+
attr : str
|
|
382
|
+
Attribute name to retrieve.
|
|
383
|
+
|
|
384
|
+
Returns
|
|
385
|
+
-------
|
|
386
|
+
Any
|
|
387
|
+
Attribute value or ``None`` if no profile is selected.
|
|
388
|
+
"""
|
|
389
|
+
prof = self._selected_profile()
|
|
390
|
+
|
|
391
|
+
return getattr(prof, attr, None) if prof else None
|
|
392
|
+
|
|
393
|
+
# -- Instance Methods -- #
|
|
394
|
+
|
|
395
|
+
def build_endpoint_url(
|
|
396
|
+
self,
|
|
397
|
+
endpoint: EndpointConfig,
|
|
398
|
+
) -> str:
|
|
399
|
+
"""
|
|
400
|
+
Compose a full URL from ``base_url``, ``base_path``, and endpoint path.
|
|
401
|
+
|
|
402
|
+
Parameters
|
|
403
|
+
----------
|
|
404
|
+
endpoint : EndpointConfig
|
|
405
|
+
Endpoint configuration.
|
|
406
|
+
|
|
407
|
+
Returns
|
|
408
|
+
-------
|
|
409
|
+
str
|
|
410
|
+
Full endpoint URL.
|
|
411
|
+
"""
|
|
412
|
+
client = EndpointClient(
|
|
413
|
+
base_url=self.base_url,
|
|
414
|
+
base_path=self.effective_base_path(),
|
|
415
|
+
endpoints={'__ep__': endpoint.path},
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
return client.url('__ep__')
|
|
419
|
+
|
|
420
|
+
def effective_base_path(self) -> str | None:
|
|
421
|
+
"""Return the selected profile's ``base_path``, if any."""
|
|
422
|
+
return self._profile_attr('base_path')
|
|
423
|
+
|
|
424
|
+
def effective_base_url(self) -> str:
|
|
425
|
+
"""
|
|
426
|
+
Compute ``base_url`` combined with effective ``base_path`` when set.
|
|
427
|
+
"""
|
|
428
|
+
parts = urlsplit(self.base_url)
|
|
429
|
+
base_path = parts.path.rstrip('/')
|
|
430
|
+
extra = self.effective_base_path()
|
|
431
|
+
extra_norm = ('/' + extra.lstrip('/')) if extra else ''
|
|
432
|
+
path = (base_path + extra_norm) if (base_path or extra_norm) else ''
|
|
433
|
+
|
|
434
|
+
return urlunsplit(
|
|
435
|
+
(parts.scheme, parts.netloc, path, parts.query, parts.fragment),
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
def effective_pagination_defaults(self) -> PaginationConfig | None:
|
|
439
|
+
"""Return selected profile ``pagination_defaults``, if any."""
|
|
440
|
+
return self._profile_attr('pagination_defaults')
|
|
441
|
+
|
|
442
|
+
def effective_rate_limit_defaults(self) -> RateLimitConfig | None:
|
|
443
|
+
"""Return selected profile ``rate_limit_defaults``, if any."""
|
|
444
|
+
return self._profile_attr('rate_limit_defaults')
|
|
445
|
+
|
|
446
|
+
# -- Class Methods -- #
|
|
447
|
+
|
|
448
|
+
@classmethod
|
|
449
|
+
@overload
|
|
450
|
+
def from_obj(
|
|
451
|
+
cls,
|
|
452
|
+
obj: ApiConfigMap,
|
|
453
|
+
) -> Self: ...
|
|
454
|
+
|
|
455
|
+
@classmethod
|
|
456
|
+
@overload
|
|
457
|
+
def from_obj(
|
|
458
|
+
cls,
|
|
459
|
+
obj: StrAnyMap,
|
|
460
|
+
) -> Self: ...
|
|
461
|
+
|
|
462
|
+
@classmethod
|
|
463
|
+
def from_obj(
|
|
464
|
+
cls,
|
|
465
|
+
obj: StrAnyMap,
|
|
466
|
+
) -> Self:
|
|
467
|
+
"""
|
|
468
|
+
Parse a mapping into an :class:`ApiConfig` instance.
|
|
469
|
+
|
|
470
|
+
Parameters
|
|
471
|
+
----------
|
|
472
|
+
obj : StrAnyMap
|
|
473
|
+
Raw API configuration.
|
|
474
|
+
|
|
475
|
+
Returns
|
|
476
|
+
-------
|
|
477
|
+
Self
|
|
478
|
+
Parsed API configuration.
|
|
479
|
+
|
|
480
|
+
Raises
|
|
481
|
+
------
|
|
482
|
+
TypeError
|
|
483
|
+
If required fields are missing or of incorrect type.
|
|
484
|
+
"""
|
|
485
|
+
if not isinstance(obj, Mapping):
|
|
486
|
+
raise TypeError('ApiConfig must be a mapping')
|
|
487
|
+
|
|
488
|
+
profiles = _parse_profiles(obj.get('profiles'))
|
|
489
|
+
|
|
490
|
+
tl_base = obj.get('base_url')
|
|
491
|
+
tl_headers = cast_str_dict(obj.get('headers'))
|
|
492
|
+
|
|
493
|
+
base_url, headers = _effective_service_defaults(
|
|
494
|
+
profiles=profiles,
|
|
495
|
+
fallback_base=tl_base,
|
|
496
|
+
fallback_headers=tl_headers,
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
endpoints = _parse_endpoints(obj.get('endpoints'))
|
|
500
|
+
|
|
501
|
+
return cls(
|
|
502
|
+
base_url=base_url,
|
|
503
|
+
headers=headers,
|
|
504
|
+
endpoints=endpoints,
|
|
505
|
+
profiles=profiles,
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
@dataclass(slots=True, kw_only=True)
|
|
510
|
+
class EndpointConfig:
|
|
511
|
+
"""
|
|
512
|
+
Configuration for a single API endpoint.
|
|
513
|
+
|
|
514
|
+
Attributes
|
|
515
|
+
----------
|
|
516
|
+
path : str
|
|
517
|
+
Endpoint path (relative to base URL).
|
|
518
|
+
method : str | None
|
|
519
|
+
Optional HTTP method (default is GET when omitted at runtime).
|
|
520
|
+
path_params : StrAnyMap
|
|
521
|
+
Path parameters used when constructing the request URL.
|
|
522
|
+
query_params : StrAnyMap
|
|
523
|
+
Default query string parameters.
|
|
524
|
+
body : Any | None
|
|
525
|
+
Request body structure (pass-through, format-specific).
|
|
526
|
+
pagination : PaginationConfig | None
|
|
527
|
+
Pagination configuration for the endpoint.
|
|
528
|
+
rate_limit : RateLimitConfig | None
|
|
529
|
+
Rate limit configuration for the endpoint.
|
|
530
|
+
"""
|
|
531
|
+
|
|
532
|
+
# -- Attributes -- #
|
|
533
|
+
|
|
534
|
+
path: str
|
|
535
|
+
method: str | None = None
|
|
536
|
+
path_params: StrAnyMap = field(default_factory=dict)
|
|
537
|
+
query_params: StrAnyMap = field(default_factory=dict)
|
|
538
|
+
body: Any | None = None
|
|
539
|
+
pagination: PaginationConfig | None = None
|
|
540
|
+
rate_limit: RateLimitConfig | None = None
|
|
541
|
+
|
|
542
|
+
# -- Magic Methods (Object Lifecycle) -- #
|
|
543
|
+
|
|
544
|
+
def __post_init__(self) -> None:
|
|
545
|
+
object.__setattr__(
|
|
546
|
+
self,
|
|
547
|
+
'path_params',
|
|
548
|
+
MappingProxyType(dict(self.path_params)),
|
|
549
|
+
)
|
|
550
|
+
object.__setattr__(
|
|
551
|
+
self,
|
|
552
|
+
'query_params',
|
|
553
|
+
MappingProxyType(dict(self.query_params)),
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
# -- Class Methods -- #
|
|
557
|
+
|
|
558
|
+
@classmethod
|
|
559
|
+
@overload
|
|
560
|
+
def from_obj(
|
|
561
|
+
cls,
|
|
562
|
+
obj: str,
|
|
563
|
+
) -> Self: ...
|
|
564
|
+
|
|
565
|
+
@classmethod
|
|
566
|
+
@overload
|
|
567
|
+
def from_obj(
|
|
568
|
+
cls,
|
|
569
|
+
obj: EndpointMap,
|
|
570
|
+
) -> Self: ...
|
|
571
|
+
|
|
572
|
+
@classmethod
|
|
573
|
+
def from_obj(
|
|
574
|
+
cls,
|
|
575
|
+
obj: str | StrAnyMap,
|
|
576
|
+
) -> Self:
|
|
577
|
+
"""
|
|
578
|
+
Parse a string or mapping into an :class:`EndpointConfig` instance.
|
|
579
|
+
|
|
580
|
+
Parameters
|
|
581
|
+
----------
|
|
582
|
+
obj : str | StrAnyMap
|
|
583
|
+
Raw endpoint configuration.
|
|
584
|
+
|
|
585
|
+
Returns
|
|
586
|
+
-------
|
|
587
|
+
Self
|
|
588
|
+
Parsed endpoint configuration.
|
|
589
|
+
|
|
590
|
+
Raises
|
|
591
|
+
------
|
|
592
|
+
TypeError
|
|
593
|
+
If required fields are missing or of incorrect type.
|
|
594
|
+
ValueError
|
|
595
|
+
If provided method is not a supported HTTP method.
|
|
596
|
+
"""
|
|
597
|
+
match obj:
|
|
598
|
+
case str():
|
|
599
|
+
return cls(path=obj, method=None)
|
|
600
|
+
case Mapping():
|
|
601
|
+
path = obj.get('path') or obj.get('url')
|
|
602
|
+
if not isinstance(path, str):
|
|
603
|
+
raise TypeError('EndpointConfig requires a "path" (str)')
|
|
604
|
+
|
|
605
|
+
path_params_raw = obj.get('path_params')
|
|
606
|
+
if path_params_raw is not None and not isinstance(
|
|
607
|
+
path_params_raw,
|
|
608
|
+
Mapping,
|
|
609
|
+
):
|
|
610
|
+
raise ValueError('path_params must be a mapping if set')
|
|
611
|
+
|
|
612
|
+
query_params_raw = obj.get('query_params')
|
|
613
|
+
if query_params_raw is not None and not isinstance(
|
|
614
|
+
query_params_raw,
|
|
615
|
+
Mapping,
|
|
616
|
+
):
|
|
617
|
+
raise TypeError('query_params must be a mapping if set')
|
|
618
|
+
|
|
619
|
+
return cls(
|
|
620
|
+
path=path,
|
|
621
|
+
method=_normalize_method(obj.get('method')),
|
|
622
|
+
path_params=coerce_dict(path_params_raw),
|
|
623
|
+
query_params=coerce_dict(query_params_raw),
|
|
624
|
+
body=obj.get('body'),
|
|
625
|
+
pagination=PaginationConfig.from_obj(
|
|
626
|
+
obj.get('pagination'),
|
|
627
|
+
),
|
|
628
|
+
rate_limit=RateLimitConfig.from_obj(obj.get('rate_limit')),
|
|
629
|
+
)
|
|
630
|
+
case _:
|
|
631
|
+
raise TypeError(
|
|
632
|
+
'Invalid endpoint config: expected str or mapping',
|
|
633
|
+
)
|