etlplus 0.5.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. etlplus/__init__.py +43 -0
  2. etlplus/__main__.py +22 -0
  3. etlplus/__version__.py +14 -0
  4. etlplus/api/README.md +237 -0
  5. etlplus/api/__init__.py +136 -0
  6. etlplus/api/auth.py +432 -0
  7. etlplus/api/config.py +633 -0
  8. etlplus/api/endpoint_client.py +885 -0
  9. etlplus/api/errors.py +170 -0
  10. etlplus/api/pagination/__init__.py +47 -0
  11. etlplus/api/pagination/client.py +188 -0
  12. etlplus/api/pagination/config.py +440 -0
  13. etlplus/api/pagination/paginator.py +775 -0
  14. etlplus/api/rate_limiting/__init__.py +38 -0
  15. etlplus/api/rate_limiting/config.py +343 -0
  16. etlplus/api/rate_limiting/rate_limiter.py +266 -0
  17. etlplus/api/request_manager.py +589 -0
  18. etlplus/api/retry_manager.py +430 -0
  19. etlplus/api/transport.py +325 -0
  20. etlplus/api/types.py +172 -0
  21. etlplus/cli/__init__.py +15 -0
  22. etlplus/cli/app.py +1367 -0
  23. etlplus/cli/handlers.py +775 -0
  24. etlplus/cli/main.py +616 -0
  25. etlplus/config/__init__.py +56 -0
  26. etlplus/config/connector.py +372 -0
  27. etlplus/config/jobs.py +311 -0
  28. etlplus/config/pipeline.py +339 -0
  29. etlplus/config/profile.py +78 -0
  30. etlplus/config/types.py +204 -0
  31. etlplus/config/utils.py +120 -0
  32. etlplus/ddl.py +197 -0
  33. etlplus/enums.py +414 -0
  34. etlplus/extract.py +218 -0
  35. etlplus/file.py +657 -0
  36. etlplus/load.py +336 -0
  37. etlplus/mixins.py +62 -0
  38. etlplus/py.typed +0 -0
  39. etlplus/run.py +368 -0
  40. etlplus/run_helpers.py +843 -0
  41. etlplus/templates/__init__.py +5 -0
  42. etlplus/templates/ddl.sql.j2 +128 -0
  43. etlplus/templates/view.sql.j2 +69 -0
  44. etlplus/transform.py +1049 -0
  45. etlplus/types.py +227 -0
  46. etlplus/utils.py +638 -0
  47. etlplus/validate.py +493 -0
  48. etlplus/validation/__init__.py +44 -0
  49. etlplus/validation/utils.py +389 -0
  50. etlplus-0.5.4.dist-info/METADATA +616 -0
  51. etlplus-0.5.4.dist-info/RECORD +55 -0
  52. etlplus-0.5.4.dist-info/WHEEL +5 -0
  53. etlplus-0.5.4.dist-info/entry_points.txt +2 -0
  54. etlplus-0.5.4.dist-info/licenses/LICENSE +21 -0
  55. etlplus-0.5.4.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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