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
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
+ )