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/cli/handlers.py
ADDED
|
@@ -0,0 +1,775 @@
|
|
|
1
|
+
"""
|
|
2
|
+
:mod:`etlplus.cli.handlers` module.
|
|
3
|
+
|
|
4
|
+
Command handler functions for the ``etlplus`` command-line interface (CLI).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import csv
|
|
11
|
+
import io
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
from typing import cast
|
|
18
|
+
|
|
19
|
+
from ..config import PipelineConfig
|
|
20
|
+
from ..config import load_pipeline_config
|
|
21
|
+
from ..ddl import load_table_spec
|
|
22
|
+
from ..ddl import render_tables
|
|
23
|
+
from ..enums import FileFormat
|
|
24
|
+
from ..extract import extract
|
|
25
|
+
from ..file import File
|
|
26
|
+
from ..load import load
|
|
27
|
+
from ..run import run
|
|
28
|
+
from ..transform import transform
|
|
29
|
+
from ..types import JSONData
|
|
30
|
+
from ..utils import json_type
|
|
31
|
+
from ..utils import print_json
|
|
32
|
+
from ..validate import validate
|
|
33
|
+
|
|
34
|
+
# SECTION: EXPORTS ========================================================== #
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
# Functions
|
|
39
|
+
'cmd_extract',
|
|
40
|
+
'cmd_check',
|
|
41
|
+
'cmd_load',
|
|
42
|
+
'cmd_pipeline',
|
|
43
|
+
'cmd_render',
|
|
44
|
+
'cmd_run',
|
|
45
|
+
'cmd_transform',
|
|
46
|
+
'cmd_validate',
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# SECTION: INTERNAL FUNCTIONS =============================================== #
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _collect_table_specs(
|
|
54
|
+
config_path: str | None,
|
|
55
|
+
spec_path: str | None,
|
|
56
|
+
) -> list[dict[str, Any]]:
|
|
57
|
+
"""
|
|
58
|
+
Load table schemas from a pipeline config and/or standalone spec.
|
|
59
|
+
|
|
60
|
+
Parameters
|
|
61
|
+
----------
|
|
62
|
+
config_path : str | None
|
|
63
|
+
Path to a pipeline YAML config file.
|
|
64
|
+
spec_path : str | None
|
|
65
|
+
Path to a standalone table spec file.
|
|
66
|
+
|
|
67
|
+
Returns
|
|
68
|
+
-------
|
|
69
|
+
list[dict[str, Any]]
|
|
70
|
+
Collected table specification mappings.
|
|
71
|
+
"""
|
|
72
|
+
specs: list[dict[str, Any]] = []
|
|
73
|
+
|
|
74
|
+
if spec_path:
|
|
75
|
+
specs.append(load_table_spec(Path(spec_path)))
|
|
76
|
+
|
|
77
|
+
if config_path:
|
|
78
|
+
cfg = load_pipeline_config(config_path, substitute=True)
|
|
79
|
+
specs.extend(getattr(cfg, 'table_schemas', []))
|
|
80
|
+
|
|
81
|
+
return specs
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _emit_json(
|
|
85
|
+
data: Any,
|
|
86
|
+
*,
|
|
87
|
+
pretty: bool,
|
|
88
|
+
) -> None:
|
|
89
|
+
"""
|
|
90
|
+
Emit JSON to stdout honoring the pretty/compact preference.
|
|
91
|
+
|
|
92
|
+
Parameters
|
|
93
|
+
----------
|
|
94
|
+
data : Any
|
|
95
|
+
Arbitrary JSON-serializable payload.
|
|
96
|
+
pretty : bool
|
|
97
|
+
When ``True`` pretty-print via :func:`print_json`; otherwise emit a
|
|
98
|
+
compact JSON string.
|
|
99
|
+
"""
|
|
100
|
+
if pretty:
|
|
101
|
+
print_json(data)
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
dumped = json.dumps(
|
|
105
|
+
data,
|
|
106
|
+
ensure_ascii=False,
|
|
107
|
+
separators=(',', ':'),
|
|
108
|
+
)
|
|
109
|
+
print(dumped)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _explicit_cli_format(
|
|
113
|
+
args: argparse.Namespace,
|
|
114
|
+
) -> str | None:
|
|
115
|
+
"""Return the explicit CLI format hint when provided."""
|
|
116
|
+
|
|
117
|
+
if not getattr(args, '_format_explicit', False):
|
|
118
|
+
return None
|
|
119
|
+
for attr in ('format', 'target_format', 'source_format'):
|
|
120
|
+
value = getattr(args, attr, None)
|
|
121
|
+
if value is None:
|
|
122
|
+
continue
|
|
123
|
+
normalized = value.strip().lower()
|
|
124
|
+
if normalized:
|
|
125
|
+
return normalized
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _infer_payload_format(
|
|
130
|
+
text: str,
|
|
131
|
+
) -> str:
|
|
132
|
+
"""
|
|
133
|
+
Infer JSON vs CSV from payload text.
|
|
134
|
+
|
|
135
|
+
Parameters
|
|
136
|
+
----------
|
|
137
|
+
text : str
|
|
138
|
+
Incoming payload as plain text.
|
|
139
|
+
|
|
140
|
+
Returns
|
|
141
|
+
-------
|
|
142
|
+
str
|
|
143
|
+
``'json'`` when the text starts with ``{``/``[``, else ``'csv'``.
|
|
144
|
+
"""
|
|
145
|
+
stripped = text.lstrip()
|
|
146
|
+
if stripped.startswith('{') or stripped.startswith('['):
|
|
147
|
+
return 'json'
|
|
148
|
+
return 'csv'
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _check_sections(
|
|
152
|
+
cfg: PipelineConfig,
|
|
153
|
+
args: argparse.Namespace,
|
|
154
|
+
) -> dict[str, Any]:
|
|
155
|
+
"""
|
|
156
|
+
Build sectioned metadata output for the check command.
|
|
157
|
+
|
|
158
|
+
Parameters
|
|
159
|
+
----------
|
|
160
|
+
cfg : PipelineConfig
|
|
161
|
+
The loaded pipeline configuration.
|
|
162
|
+
args : argparse.Namespace
|
|
163
|
+
Parsed command-line arguments.
|
|
164
|
+
|
|
165
|
+
Returns
|
|
166
|
+
-------
|
|
167
|
+
dict[str, Any]
|
|
168
|
+
Metadata output for the check command.
|
|
169
|
+
"""
|
|
170
|
+
sections: dict[str, Any] = {}
|
|
171
|
+
if getattr(args, 'jobs', False):
|
|
172
|
+
sections['jobs'] = _pipeline_summary(cfg)['jobs']
|
|
173
|
+
if getattr(args, 'pipelines', False):
|
|
174
|
+
sections['pipelines'] = [cfg.name]
|
|
175
|
+
if getattr(args, 'sources', False):
|
|
176
|
+
sections['sources'] = [src.name for src in cfg.sources]
|
|
177
|
+
if getattr(args, 'targets', False):
|
|
178
|
+
sections['targets'] = [tgt.name for tgt in cfg.targets]
|
|
179
|
+
if getattr(args, 'transforms', False):
|
|
180
|
+
sections['transforms'] = [
|
|
181
|
+
getattr(trf, 'name', None) for trf in cfg.transforms
|
|
182
|
+
]
|
|
183
|
+
if not sections:
|
|
184
|
+
sections['jobs'] = _pipeline_summary(cfg)['jobs']
|
|
185
|
+
return sections
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _materialize_file_payload(
|
|
189
|
+
source: object,
|
|
190
|
+
*,
|
|
191
|
+
format_hint: str | None,
|
|
192
|
+
format_explicit: bool,
|
|
193
|
+
) -> JSONData | object:
|
|
194
|
+
"""
|
|
195
|
+
Return structured payloads when ``source`` references a file.
|
|
196
|
+
|
|
197
|
+
Parameters
|
|
198
|
+
----------
|
|
199
|
+
source : object
|
|
200
|
+
Input source of data, possibly a file path.
|
|
201
|
+
format_hint : str | None
|
|
202
|
+
Explicit format hint: 'json', 'csv', or None to infer.
|
|
203
|
+
format_explicit : bool
|
|
204
|
+
Whether an explicit format hint was provided.
|
|
205
|
+
|
|
206
|
+
Returns
|
|
207
|
+
-------
|
|
208
|
+
JSONData | object
|
|
209
|
+
Parsed JSON data when ``source`` is a file; otherwise the original
|
|
210
|
+
``source`` object.
|
|
211
|
+
"""
|
|
212
|
+
if isinstance(source, (dict, list)):
|
|
213
|
+
return cast(JSONData, source)
|
|
214
|
+
if not isinstance(source, (str, os.PathLike)):
|
|
215
|
+
return source
|
|
216
|
+
|
|
217
|
+
path = Path(source)
|
|
218
|
+
|
|
219
|
+
normalized_hint = (format_hint or '').strip().lower()
|
|
220
|
+
fmt: FileFormat | None = None
|
|
221
|
+
|
|
222
|
+
if format_explicit and normalized_hint:
|
|
223
|
+
try:
|
|
224
|
+
fmt = FileFormat(normalized_hint)
|
|
225
|
+
except ValueError:
|
|
226
|
+
fmt = None
|
|
227
|
+
elif not format_explicit:
|
|
228
|
+
suffix = path.suffix.lower().lstrip('.')
|
|
229
|
+
if suffix:
|
|
230
|
+
try:
|
|
231
|
+
fmt = FileFormat(suffix)
|
|
232
|
+
except ValueError:
|
|
233
|
+
fmt = None
|
|
234
|
+
|
|
235
|
+
if fmt is None:
|
|
236
|
+
return source
|
|
237
|
+
if fmt == FileFormat.CSV:
|
|
238
|
+
return _read_csv_rows(path)
|
|
239
|
+
return File(path, fmt).read()
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _parse_text_payload(
|
|
243
|
+
text: str,
|
|
244
|
+
fmt: str | None,
|
|
245
|
+
) -> JSONData | str:
|
|
246
|
+
"""
|
|
247
|
+
Parse JSON/CSV text into a Python payload.
|
|
248
|
+
|
|
249
|
+
Parameters
|
|
250
|
+
----------
|
|
251
|
+
text : str
|
|
252
|
+
The input text payload.
|
|
253
|
+
fmt : str | None
|
|
254
|
+
Explicit format hint: 'json', 'csv', or None to infer.
|
|
255
|
+
|
|
256
|
+
Returns
|
|
257
|
+
-------
|
|
258
|
+
JSONData | str
|
|
259
|
+
The parsed payload as JSON data or raw text.
|
|
260
|
+
"""
|
|
261
|
+
effective = (fmt or '').strip().lower() or _infer_payload_format(text)
|
|
262
|
+
if effective == 'json':
|
|
263
|
+
return cast(JSONData, json_type(text))
|
|
264
|
+
if effective == 'csv':
|
|
265
|
+
reader = csv.DictReader(io.StringIO(text))
|
|
266
|
+
return [dict(row) for row in reader]
|
|
267
|
+
return text
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _pipeline_summary(
|
|
271
|
+
cfg: PipelineConfig,
|
|
272
|
+
) -> dict[str, Any]:
|
|
273
|
+
"""
|
|
274
|
+
Return a human-friendly snapshot of a pipeline config.
|
|
275
|
+
|
|
276
|
+
Parameters
|
|
277
|
+
----------
|
|
278
|
+
cfg : PipelineConfig
|
|
279
|
+
The loaded pipeline configuration.
|
|
280
|
+
|
|
281
|
+
Returns
|
|
282
|
+
-------
|
|
283
|
+
dict[str, Any]
|
|
284
|
+
A human-friendly snapshot of a pipeline config.
|
|
285
|
+
"""
|
|
286
|
+
sources = [src.name for src in cfg.sources]
|
|
287
|
+
targets = [tgt.name for tgt in cfg.targets]
|
|
288
|
+
jobs = [job.name for job in cfg.jobs]
|
|
289
|
+
return {
|
|
290
|
+
'name': cfg.name,
|
|
291
|
+
'version': cfg.version,
|
|
292
|
+
'sources': sources,
|
|
293
|
+
'targets': targets,
|
|
294
|
+
'jobs': jobs,
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _presentation_flags(
|
|
299
|
+
args: argparse.Namespace,
|
|
300
|
+
) -> tuple[bool, bool]:
|
|
301
|
+
"""
|
|
302
|
+
Return presentation toggles from the parsed namespace.
|
|
303
|
+
|
|
304
|
+
Parameters
|
|
305
|
+
----------
|
|
306
|
+
args : argparse.Namespace
|
|
307
|
+
Namespace produced by the CLI parser.
|
|
308
|
+
|
|
309
|
+
Returns
|
|
310
|
+
-------
|
|
311
|
+
tuple[bool, bool]
|
|
312
|
+
Pair of ``(pretty, quiet)`` flags with safe defaults.
|
|
313
|
+
"""
|
|
314
|
+
return getattr(args, 'pretty', True), getattr(args, 'quiet', False)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _read_csv_rows(
|
|
318
|
+
path: Path,
|
|
319
|
+
) -> list[dict[str, str]]:
|
|
320
|
+
"""
|
|
321
|
+
Read CSV rows into dictionaries.
|
|
322
|
+
|
|
323
|
+
Parameters
|
|
324
|
+
----------
|
|
325
|
+
path : Path
|
|
326
|
+
Path to a CSV file.
|
|
327
|
+
|
|
328
|
+
Returns
|
|
329
|
+
-------
|
|
330
|
+
list[dict[str, str]]
|
|
331
|
+
List of dictionaries, each representing a row in the CSV file.
|
|
332
|
+
"""
|
|
333
|
+
with path.open(newline='', encoding='utf-8') as handle:
|
|
334
|
+
reader = csv.DictReader(handle)
|
|
335
|
+
return [dict(row) for row in reader]
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _read_stdin_text() -> str:
|
|
339
|
+
"""
|
|
340
|
+
Return every character from ``stdin`` as a single string.
|
|
341
|
+
|
|
342
|
+
Returns
|
|
343
|
+
-------
|
|
344
|
+
str
|
|
345
|
+
Entire ``stdin`` contents.
|
|
346
|
+
"""
|
|
347
|
+
return sys.stdin.read()
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _resolve_cli_payload(
|
|
351
|
+
source: object,
|
|
352
|
+
*,
|
|
353
|
+
format_hint: str | None,
|
|
354
|
+
format_explicit: bool,
|
|
355
|
+
hydrate_files: bool = True,
|
|
356
|
+
) -> JSONData | object:
|
|
357
|
+
"""
|
|
358
|
+
Normalize CLI-provided payloads, honoring stdin and inline data.
|
|
359
|
+
|
|
360
|
+
Parameters
|
|
361
|
+
----------
|
|
362
|
+
source : object
|
|
363
|
+
Raw CLI value (path, inline payload, or ``'-'`` for stdin).
|
|
364
|
+
format_hint : str | None
|
|
365
|
+
Explicit format hint supplied by the CLI option.
|
|
366
|
+
format_explicit : bool
|
|
367
|
+
Flag indicating whether the format hint was explicitly provided.
|
|
368
|
+
hydrate_files : bool, optional
|
|
369
|
+
When ``True`` (default) materialize file paths into structured data.
|
|
370
|
+
When ``False``, keep the original path so downstream code can stream
|
|
371
|
+
from disk directly.
|
|
372
|
+
|
|
373
|
+
Returns
|
|
374
|
+
-------
|
|
375
|
+
JSONData | object
|
|
376
|
+
Parsed payload or the original source value when hydration is
|
|
377
|
+
disabled.
|
|
378
|
+
"""
|
|
379
|
+
if isinstance(source, (os.PathLike, str)) and str(source) == '-':
|
|
380
|
+
text = _read_stdin_text()
|
|
381
|
+
return _parse_text_payload(text, format_hint)
|
|
382
|
+
|
|
383
|
+
if not hydrate_files:
|
|
384
|
+
return source
|
|
385
|
+
|
|
386
|
+
return _materialize_file_payload(
|
|
387
|
+
source,
|
|
388
|
+
format_hint=format_hint,
|
|
389
|
+
format_explicit=format_explicit,
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _write_json_output(
|
|
394
|
+
data: Any,
|
|
395
|
+
output_path: str | None,
|
|
396
|
+
*,
|
|
397
|
+
success_message: str,
|
|
398
|
+
) -> bool:
|
|
399
|
+
"""
|
|
400
|
+
Optionally persist JSON data to disk.
|
|
401
|
+
|
|
402
|
+
Parameters
|
|
403
|
+
----------
|
|
404
|
+
data : Any
|
|
405
|
+
Data to write.
|
|
406
|
+
output_path : str | None
|
|
407
|
+
Path to write the output to. None to print to stdout.
|
|
408
|
+
success_message : str
|
|
409
|
+
Message to print upon successful write.
|
|
410
|
+
|
|
411
|
+
Returns
|
|
412
|
+
-------
|
|
413
|
+
bool
|
|
414
|
+
True if output was written to a file, False if printed to stdout.
|
|
415
|
+
"""
|
|
416
|
+
if not output_path or output_path == '-':
|
|
417
|
+
return False
|
|
418
|
+
File(Path(output_path), FileFormat.JSON).write_json(data)
|
|
419
|
+
print(f'{success_message} {output_path}')
|
|
420
|
+
return True
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
# SECTION: FUNCTIONS ======================================================== #
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def cmd_check(
|
|
427
|
+
args: argparse.Namespace,
|
|
428
|
+
) -> int:
|
|
429
|
+
"""
|
|
430
|
+
Print requested pipeline sections from a YAML configuration.
|
|
431
|
+
|
|
432
|
+
Parameters
|
|
433
|
+
----------
|
|
434
|
+
args : argparse.Namespace
|
|
435
|
+
Parsed command-line arguments.
|
|
436
|
+
|
|
437
|
+
Returns
|
|
438
|
+
-------
|
|
439
|
+
int
|
|
440
|
+
Zero on success.
|
|
441
|
+
"""
|
|
442
|
+
cfg = load_pipeline_config(args.config, substitute=True)
|
|
443
|
+
if getattr(args, 'summary', False):
|
|
444
|
+
print_json(_pipeline_summary(cfg))
|
|
445
|
+
return 0
|
|
446
|
+
|
|
447
|
+
print_json(_check_sections(cfg, args))
|
|
448
|
+
return 0
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def cmd_extract(
|
|
452
|
+
args: argparse.Namespace,
|
|
453
|
+
) -> int:
|
|
454
|
+
"""
|
|
455
|
+
Extract data from a source.
|
|
456
|
+
|
|
457
|
+
Parameters
|
|
458
|
+
----------
|
|
459
|
+
args : argparse.Namespace
|
|
460
|
+
Parsed command-line arguments.
|
|
461
|
+
|
|
462
|
+
Returns
|
|
463
|
+
-------
|
|
464
|
+
int
|
|
465
|
+
Zero on success.
|
|
466
|
+
"""
|
|
467
|
+
pretty, _ = _presentation_flags(args)
|
|
468
|
+
explicit_format = _explicit_cli_format(args)
|
|
469
|
+
|
|
470
|
+
if args.source == '-':
|
|
471
|
+
text = _read_stdin_text()
|
|
472
|
+
payload = _parse_text_payload(text, getattr(args, 'format', None))
|
|
473
|
+
_emit_json(payload, pretty=pretty)
|
|
474
|
+
|
|
475
|
+
return 0
|
|
476
|
+
|
|
477
|
+
result = extract(
|
|
478
|
+
args.source_type,
|
|
479
|
+
args.source,
|
|
480
|
+
file_format=explicit_format,
|
|
481
|
+
)
|
|
482
|
+
output_path = getattr(args, 'target', None)
|
|
483
|
+
if output_path is None:
|
|
484
|
+
output_path = getattr(args, 'output', None)
|
|
485
|
+
|
|
486
|
+
if not _write_json_output(
|
|
487
|
+
result,
|
|
488
|
+
output_path,
|
|
489
|
+
success_message='Data extracted and saved to',
|
|
490
|
+
):
|
|
491
|
+
_emit_json(result, pretty=pretty)
|
|
492
|
+
|
|
493
|
+
return 0
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def cmd_load(
|
|
497
|
+
args: argparse.Namespace,
|
|
498
|
+
) -> int:
|
|
499
|
+
"""
|
|
500
|
+
Load data into a target.
|
|
501
|
+
|
|
502
|
+
Parameters
|
|
503
|
+
----------
|
|
504
|
+
args : argparse.Namespace
|
|
505
|
+
Parsed command-line arguments.
|
|
506
|
+
|
|
507
|
+
Returns
|
|
508
|
+
-------
|
|
509
|
+
int
|
|
510
|
+
Zero on success.
|
|
511
|
+
"""
|
|
512
|
+
pretty, _ = _presentation_flags(args)
|
|
513
|
+
explicit_format = _explicit_cli_format(args)
|
|
514
|
+
|
|
515
|
+
# Allow piping into load.
|
|
516
|
+
source_format = getattr(args, 'source_format', None)
|
|
517
|
+
source_value = cast(
|
|
518
|
+
str | Path | os.PathLike[str] | dict[str, Any] | list[dict[str, Any]],
|
|
519
|
+
_resolve_cli_payload(
|
|
520
|
+
args.source,
|
|
521
|
+
format_hint=source_format,
|
|
522
|
+
format_explicit=source_format is not None,
|
|
523
|
+
hydrate_files=False,
|
|
524
|
+
),
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
# Allow piping out of load for file targets.
|
|
528
|
+
if args.target_type == 'file' and args.target == '-':
|
|
529
|
+
payload = _materialize_file_payload(
|
|
530
|
+
source_value,
|
|
531
|
+
format_hint=source_format,
|
|
532
|
+
format_explicit=source_format is not None,
|
|
533
|
+
)
|
|
534
|
+
_emit_json(payload, pretty=pretty)
|
|
535
|
+
return 0
|
|
536
|
+
|
|
537
|
+
result = load(
|
|
538
|
+
source_value,
|
|
539
|
+
args.target_type,
|
|
540
|
+
args.target,
|
|
541
|
+
file_format=explicit_format,
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
output_path = getattr(args, 'output', None)
|
|
545
|
+
if not _write_json_output(
|
|
546
|
+
result,
|
|
547
|
+
output_path,
|
|
548
|
+
success_message='Load result saved to',
|
|
549
|
+
):
|
|
550
|
+
_emit_json(result, pretty=pretty)
|
|
551
|
+
|
|
552
|
+
return 0
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def cmd_pipeline(
|
|
556
|
+
args: argparse.Namespace,
|
|
557
|
+
) -> int:
|
|
558
|
+
"""
|
|
559
|
+
Inspect or run a pipeline YAML configuration.
|
|
560
|
+
|
|
561
|
+
Parameters
|
|
562
|
+
----------
|
|
563
|
+
args : argparse.Namespace
|
|
564
|
+
Parsed command-line arguments.
|
|
565
|
+
|
|
566
|
+
Returns
|
|
567
|
+
-------
|
|
568
|
+
int
|
|
569
|
+
Zero on success.
|
|
570
|
+
"""
|
|
571
|
+
print(
|
|
572
|
+
'DEPRECATED: use "etlplus check --summary|--jobs" or '
|
|
573
|
+
'"etlplus run --job/--pipeline" instead of "etlplus pipeline".',
|
|
574
|
+
file=sys.stderr,
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
cfg = load_pipeline_config(args.config, substitute=True)
|
|
578
|
+
|
|
579
|
+
list_flag = getattr(args, 'list', False) or getattr(args, 'jobs', False)
|
|
580
|
+
run_target = (
|
|
581
|
+
getattr(args, 'run', None)
|
|
582
|
+
or getattr(args, 'job', None)
|
|
583
|
+
or getattr(args, 'pipeline', None)
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
if list_flag and not run_target:
|
|
587
|
+
print_json({'jobs': _pipeline_summary(cfg)['jobs']})
|
|
588
|
+
return 0
|
|
589
|
+
|
|
590
|
+
if run_target:
|
|
591
|
+
result = run(job=run_target, config_path=args.config)
|
|
592
|
+
print_json({'status': 'ok', 'result': result})
|
|
593
|
+
return 0
|
|
594
|
+
|
|
595
|
+
print_json(_pipeline_summary(cfg))
|
|
596
|
+
return 0
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def cmd_render(
|
|
600
|
+
args: argparse.Namespace,
|
|
601
|
+
) -> int:
|
|
602
|
+
"""Render SQL DDL statements from table schema specs."""
|
|
603
|
+
|
|
604
|
+
_pretty, quiet = _presentation_flags(args)
|
|
605
|
+
|
|
606
|
+
template_value = getattr(args, 'template', 'ddl') or 'ddl'
|
|
607
|
+
template_path = getattr(args, 'template_path', None)
|
|
608
|
+
table_filter = getattr(args, 'table', None)
|
|
609
|
+
spec_path = getattr(args, 'spec', None)
|
|
610
|
+
config_path = getattr(args, 'config', None)
|
|
611
|
+
|
|
612
|
+
# If the provided template points to a file, treat it as a path override.
|
|
613
|
+
file_override = template_path
|
|
614
|
+
template_key = template_value
|
|
615
|
+
if template_path is None:
|
|
616
|
+
candidate_path = Path(template_value)
|
|
617
|
+
if candidate_path.exists():
|
|
618
|
+
file_override = str(candidate_path)
|
|
619
|
+
template_key = None
|
|
620
|
+
|
|
621
|
+
specs = _collect_table_specs(config_path, spec_path)
|
|
622
|
+
if table_filter:
|
|
623
|
+
specs = [
|
|
624
|
+
spec
|
|
625
|
+
for spec in specs
|
|
626
|
+
if str(spec.get('table')) == table_filter
|
|
627
|
+
or str(spec.get('name', '')) == table_filter
|
|
628
|
+
]
|
|
629
|
+
|
|
630
|
+
if not specs:
|
|
631
|
+
target_desc = table_filter or 'table_schemas'
|
|
632
|
+
print(
|
|
633
|
+
'No table schemas found for '
|
|
634
|
+
f'{target_desc}. Provide --spec or a pipeline --config with '
|
|
635
|
+
'table_schemas.',
|
|
636
|
+
file=sys.stderr,
|
|
637
|
+
)
|
|
638
|
+
return 1
|
|
639
|
+
|
|
640
|
+
rendered_chunks = render_tables(
|
|
641
|
+
specs,
|
|
642
|
+
template=template_key,
|
|
643
|
+
template_path=file_override,
|
|
644
|
+
)
|
|
645
|
+
sql_text = (
|
|
646
|
+
'\n'.join(chunk.rstrip() for chunk in rendered_chunks).rstrip() + '\n'
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
output_path = getattr(args, 'output', None)
|
|
650
|
+
if output_path and output_path != '-':
|
|
651
|
+
Path(output_path).write_text(sql_text, encoding='utf-8')
|
|
652
|
+
if not quiet:
|
|
653
|
+
print(f'Rendered {len(specs)} schema(s) to {output_path}')
|
|
654
|
+
return 0
|
|
655
|
+
|
|
656
|
+
print(sql_text)
|
|
657
|
+
return 0
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def cmd_run(
|
|
661
|
+
args: argparse.Namespace,
|
|
662
|
+
) -> int:
|
|
663
|
+
"""
|
|
664
|
+
Execute an ETL job end-to-end from a pipeline YAML configuration.
|
|
665
|
+
|
|
666
|
+
Parameters
|
|
667
|
+
----------
|
|
668
|
+
args : argparse.Namespace
|
|
669
|
+
Parsed command-line arguments.
|
|
670
|
+
|
|
671
|
+
Returns
|
|
672
|
+
-------
|
|
673
|
+
int
|
|
674
|
+
Zero on success.
|
|
675
|
+
"""
|
|
676
|
+
cfg = load_pipeline_config(args.config, substitute=True)
|
|
677
|
+
|
|
678
|
+
job_name = getattr(args, 'job', None) or getattr(args, 'pipeline', None)
|
|
679
|
+
if job_name:
|
|
680
|
+
result = run(job=job_name, config_path=args.config)
|
|
681
|
+
print_json({'status': 'ok', 'result': result})
|
|
682
|
+
return 0
|
|
683
|
+
|
|
684
|
+
print_json(_pipeline_summary(cfg))
|
|
685
|
+
return 0
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def cmd_transform(
|
|
689
|
+
args: argparse.Namespace,
|
|
690
|
+
) -> int:
|
|
691
|
+
"""
|
|
692
|
+
Transform data from a source.
|
|
693
|
+
|
|
694
|
+
Parameters
|
|
695
|
+
----------
|
|
696
|
+
args : argparse.Namespace
|
|
697
|
+
Parsed command-line arguments.
|
|
698
|
+
|
|
699
|
+
Returns
|
|
700
|
+
-------
|
|
701
|
+
int
|
|
702
|
+
Zero on success.
|
|
703
|
+
"""
|
|
704
|
+
pretty, _quiet = _presentation_flags(args)
|
|
705
|
+
format_hint: str | None = getattr(args, 'source_format', None)
|
|
706
|
+
format_explicit: bool = format_hint is not None
|
|
707
|
+
|
|
708
|
+
payload = cast(
|
|
709
|
+
JSONData | str,
|
|
710
|
+
_resolve_cli_payload(
|
|
711
|
+
args.source,
|
|
712
|
+
format_hint=format_hint,
|
|
713
|
+
format_explicit=format_explicit,
|
|
714
|
+
),
|
|
715
|
+
)
|
|
716
|
+
|
|
717
|
+
data = transform(payload, args.operations)
|
|
718
|
+
|
|
719
|
+
if not _write_json_output(
|
|
720
|
+
data,
|
|
721
|
+
getattr(args, 'target', None),
|
|
722
|
+
success_message='Data transformed and saved to',
|
|
723
|
+
):
|
|
724
|
+
_emit_json(data, pretty=pretty)
|
|
725
|
+
|
|
726
|
+
return 0
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def cmd_validate(
|
|
730
|
+
args: argparse.Namespace,
|
|
731
|
+
) -> int:
|
|
732
|
+
"""
|
|
733
|
+
Validate data from a source.
|
|
734
|
+
|
|
735
|
+
Parameters
|
|
736
|
+
----------
|
|
737
|
+
args : argparse.Namespace
|
|
738
|
+
Parsed command-line arguments.
|
|
739
|
+
|
|
740
|
+
Returns
|
|
741
|
+
-------
|
|
742
|
+
int
|
|
743
|
+
Zero on success.
|
|
744
|
+
"""
|
|
745
|
+
pretty, _quiet = _presentation_flags(args)
|
|
746
|
+
format_explicit: bool = getattr(args, '_format_explicit', False)
|
|
747
|
+
format_hint: str | None = getattr(args, 'source_format', None)
|
|
748
|
+
payload = cast(
|
|
749
|
+
JSONData | str,
|
|
750
|
+
_resolve_cli_payload(
|
|
751
|
+
args.source,
|
|
752
|
+
format_hint=format_hint,
|
|
753
|
+
format_explicit=format_explicit,
|
|
754
|
+
),
|
|
755
|
+
)
|
|
756
|
+
result = validate(payload, args.rules)
|
|
757
|
+
|
|
758
|
+
target_path = getattr(args, 'target', None)
|
|
759
|
+
if target_path:
|
|
760
|
+
validated_data = result.get('data')
|
|
761
|
+
if validated_data is not None:
|
|
762
|
+
_write_json_output(
|
|
763
|
+
validated_data,
|
|
764
|
+
target_path,
|
|
765
|
+
success_message='Validation result saved to',
|
|
766
|
+
)
|
|
767
|
+
else:
|
|
768
|
+
print(
|
|
769
|
+
f'Validation failed, no data to save for {target_path}',
|
|
770
|
+
file=sys.stderr,
|
|
771
|
+
)
|
|
772
|
+
else:
|
|
773
|
+
_emit_json(result, pretty=pretty)
|
|
774
|
+
|
|
775
|
+
return 0
|