etlplus 0.7.0__py3-none-any.whl → 0.9.0__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.
@@ -0,0 +1,924 @@
1
+ """
2
+ :mod:`etlplus.cli.commands` module.
3
+
4
+ Typer application and subcommands for the ``etlplus`` command-line interface
5
+ (CLI). Typer (Click) is used for CLI parsing, help text, and subcommand
6
+ dispatch. The Typer layer focuses on ergonomics (git-style subcommands,
7
+ optional inference of resource types, STDIN/STDOUT piping, and quality-of-life
8
+ flags), while delegating business logic to the existing :func:`*_handler`
9
+ handlers.
10
+
11
+ Subcommands
12
+ -----------
13
+ - ``check``: inspect a pipeline configuration
14
+ - ``extract``: extract data from files, databases, or REST APIs
15
+ - ``load``: load data to files, databases, or REST APIs
16
+ - ``render``: render SQL DDL from table schema specs
17
+ - ``transform``: transform records
18
+ - ``validate``: validate data against rules
19
+
20
+ Notes
21
+ -----
22
+ - Use ``-`` to read from STDIN or to write to STDOUT.
23
+ - Commands ``extract`` and ``transform`` support the command-line option
24
+ ``--source-type`` to override inferred resource types.
25
+ - Commands ``transform`` and ``load`` support the command-line option
26
+ ``--target-type`` to override inferred resource types.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from typing import Annotated
32
+ from typing import Any
33
+ from typing import Literal
34
+ from typing import cast
35
+
36
+ import typer
37
+
38
+ from .. import __version__
39
+ from ..enums import FileFormat
40
+ from . import handlers
41
+ from .constants import CLI_DESCRIPTION
42
+ from .constants import CLI_EPILOG
43
+ from .constants import DATA_CONNECTORS
44
+ from .constants import FILE_FORMATS
45
+ from .io import parse_json_payload
46
+ from .options import typer_format_option_kwargs
47
+ from .state import CliState
48
+ from .state import ensure_state
49
+ from .state import infer_resource_type_or_exit
50
+ from .state import infer_resource_type_soft
51
+ from .state import log_inferred_resource
52
+ from .state import optional_choice
53
+ from .state import resolve_resource_type
54
+ from .state import validate_choice
55
+
56
+ # SECTION: EXPORTS ========================================================== #
57
+
58
+
59
+ __all__ = ['app']
60
+
61
+
62
+ # SECTION: TYPE ALIASES ==================================================== #
63
+
64
+ OperationsOption = Annotated[
65
+ str,
66
+ typer.Option(
67
+ '--operations',
68
+ help='Transformation operations as JSON string.',
69
+ ),
70
+ ]
71
+
72
+ OutputOption = Annotated[
73
+ str | None,
74
+ typer.Option(
75
+ '--output',
76
+ '-o',
77
+ metavar='PATH',
78
+ help='Write output to file PATH (default: STDOUT).',
79
+ ),
80
+ ]
81
+
82
+ PipelineConfigOption = Annotated[
83
+ str,
84
+ typer.Option(
85
+ ...,
86
+ '--config',
87
+ metavar='PATH',
88
+ help='Path to pipeline YAML configuration file.',
89
+ ),
90
+ ]
91
+
92
+ RenderConfigOption = Annotated[
93
+ str | None,
94
+ typer.Option(
95
+ '--config',
96
+ metavar='PATH',
97
+ help='Pipeline YAML that includes table_schemas for rendering.',
98
+ show_default=False,
99
+ ),
100
+ ]
101
+
102
+ RenderOutputOption = Annotated[
103
+ str | None,
104
+ typer.Option(
105
+ '--output',
106
+ '-o',
107
+ metavar='PATH',
108
+ help='Write rendered SQL to PATH (default: STDOUT).',
109
+ ),
110
+ ]
111
+
112
+ RenderSpecOption = Annotated[
113
+ str | None,
114
+ typer.Option(
115
+ '--spec',
116
+ metavar='PATH',
117
+ help='Standalone table spec file (.yml/.yaml/.json).',
118
+ show_default=False,
119
+ ),
120
+ ]
121
+
122
+ RenderTableOption = Annotated[
123
+ str | None,
124
+ typer.Option(
125
+ '--table',
126
+ metavar='NAME',
127
+ help='Filter to a single table name from table_schemas.',
128
+ ),
129
+ ]
130
+
131
+ RenderTemplateOption = Annotated[
132
+ Literal['ddl', 'view'] | None,
133
+ typer.Option(
134
+ '--template',
135
+ '-t',
136
+ metavar='KEY',
137
+ help='Template key (ddl/view).',
138
+ show_default=True,
139
+ ),
140
+ ]
141
+
142
+ RenderTemplatePathOption = Annotated[
143
+ str | None,
144
+ typer.Option(
145
+ '--template-path',
146
+ metavar='PATH',
147
+ help=(
148
+ 'Explicit path to a Jinja template file (overrides template key).'
149
+ ),
150
+ ),
151
+ ]
152
+
153
+ RulesOption = Annotated[
154
+ str,
155
+ typer.Option(
156
+ '--rules',
157
+ help='Validation rules as JSON string.',
158
+ ),
159
+ ]
160
+
161
+ SourceArg = Annotated[
162
+ str,
163
+ typer.Argument(
164
+ ...,
165
+ metavar='SOURCE',
166
+ help=(
167
+ 'Extract data from SOURCE (JSON payload, file/folder path, '
168
+ 'URI/URL, or - for STDIN). Use --source-format to override the '
169
+ 'inferred data format and --source-type to override the inferred '
170
+ 'data connector.'
171
+ ),
172
+ ),
173
+ ]
174
+
175
+ SourceFormatOption = Annotated[
176
+ FileFormat | None,
177
+ typer.Option(
178
+ '--source-format',
179
+ **typer_format_option_kwargs(context='source'),
180
+ ),
181
+ ]
182
+
183
+ SourceTypeOption = Annotated[
184
+ str | None,
185
+ typer.Option(
186
+ '--source-type',
187
+ metavar='CONNECTOR',
188
+ show_default=False,
189
+ rich_help_panel='I/O overrides',
190
+ help=(
191
+ 'Override the inferred source type (api, database, file, folder).'
192
+ ),
193
+ ),
194
+ ]
195
+
196
+ TargetArg = Annotated[
197
+ str,
198
+ typer.Argument(
199
+ ...,
200
+ metavar='TARGET',
201
+ help=(
202
+ 'Load data into TARGET (file/folder path, URI/URL, or - for '
203
+ 'STDOUT). Use --target-format to override the inferred data '
204
+ 'format and --target-type to override the inferred data connector.'
205
+ ),
206
+ ),
207
+ ]
208
+
209
+ TargetFormatOption = Annotated[
210
+ FileFormat | None,
211
+ typer.Option(
212
+ '--target-format',
213
+ **typer_format_option_kwargs(context='target'),
214
+ ),
215
+ ]
216
+
217
+ TargetTypeOption = Annotated[
218
+ str | None,
219
+ typer.Option(
220
+ '--target-type',
221
+ metavar='CONNECTOR',
222
+ show_default=False,
223
+ rich_help_panel='I/O overrides',
224
+ help=(
225
+ 'Override the inferred target type (api, database, file, folder).'
226
+ ),
227
+ ),
228
+ ]
229
+
230
+
231
+ # SECTION: INTERNAL FUNCTIONS =============================================== #
232
+
233
+
234
+ def _parse_json_option(
235
+ value: str,
236
+ flag: str,
237
+ ) -> Any:
238
+ """
239
+ Parse JSON option values and surface a helpful CLI error.
240
+
241
+ Parameters
242
+ ----------
243
+ value : str
244
+ The JSON string to parse.
245
+ flag : str
246
+ The CLI flag name for error messages.
247
+
248
+ Returns
249
+ -------
250
+ Any
251
+ The parsed JSON value.
252
+
253
+ Raises
254
+ ------
255
+ typer.BadParameter
256
+ When the JSON is invalid.
257
+ """
258
+ try:
259
+ return parse_json_payload(value)
260
+ except ValueError as e:
261
+ raise typer.BadParameter(
262
+ f'Invalid JSON for {flag}: {e}',
263
+ ) from e
264
+
265
+
266
+ # SECTION: TYPER APP ======================================================== #
267
+
268
+
269
+ app = typer.Typer(
270
+ name='etlplus',
271
+ help=CLI_DESCRIPTION,
272
+ epilog=CLI_EPILOG,
273
+ add_completion=True,
274
+ no_args_is_help=False,
275
+ rich_markup_mode='markdown',
276
+ )
277
+
278
+
279
+ @app.callback(invoke_without_command=True)
280
+ def _root(
281
+ ctx: typer.Context,
282
+ version: bool = typer.Option(
283
+ False,
284
+ '--version',
285
+ '-V',
286
+ is_eager=True,
287
+ help='Show the version and exit.',
288
+ ),
289
+ pretty: bool = typer.Option(
290
+ True,
291
+ '--pretty/--no-pretty',
292
+ help='Pretty-print JSON output (default: pretty).',
293
+ ),
294
+ quiet: bool = typer.Option(
295
+ False,
296
+ '--quiet',
297
+ '-q',
298
+ help='Suppress warnings and non-essential output.',
299
+ ),
300
+ verbose: bool = typer.Option(
301
+ False,
302
+ '--verbose',
303
+ '-v',
304
+ help='Emit extra diagnostics to STDERR.',
305
+ ),
306
+ ) -> None:
307
+ """
308
+ Seed the Typer context with runtime flags and handle root-only options.
309
+
310
+ Parameters
311
+ ----------
312
+ ctx : typer.Context
313
+ The Typer command context.
314
+ version : bool, optional
315
+ Show the version and exit. Default is ``False``.
316
+ pretty : bool, optional
317
+ Whether to pretty-print JSON output. Default is ``True``.
318
+ quiet : bool, optional
319
+ Whether to suppress warnings and non-essential output. Default is
320
+ ``False``.
321
+ verbose : bool, optional
322
+ Whether to emit extra diagnostics to STDERR. Default is ``False``.
323
+
324
+ Raises
325
+ ------
326
+ typer.Exit
327
+ When ``--version`` is provided or no subcommand is invoked.
328
+ """
329
+ ctx.obj = CliState(pretty=pretty, quiet=quiet, verbose=verbose)
330
+
331
+ if version:
332
+ typer.echo(f'etlplus {__version__}')
333
+ raise typer.Exit(0)
334
+
335
+ if ctx.invoked_subcommand is None and not ctx.resilient_parsing:
336
+ typer.echo(ctx.command.get_help(ctx))
337
+ raise typer.Exit(0)
338
+
339
+
340
+ @app.command('check')
341
+ def check_cmd(
342
+ ctx: typer.Context,
343
+ config: PipelineConfigOption,
344
+ jobs: bool = typer.Option(
345
+ False,
346
+ '--jobs',
347
+ help='List available job names and exit',
348
+ ),
349
+ pipelines: bool = typer.Option(
350
+ False,
351
+ '--pipelines',
352
+ help='List ETL pipelines',
353
+ ),
354
+ sources: bool = typer.Option(
355
+ False,
356
+ '--sources',
357
+ help='List data sources',
358
+ ),
359
+ summary: bool = typer.Option(
360
+ False,
361
+ '--summary',
362
+ help='Show pipeline summary (name, version, sources, targets, jobs)',
363
+ ),
364
+ targets: bool = typer.Option(
365
+ False,
366
+ '--targets',
367
+ help='List data targets',
368
+ ),
369
+ transforms: bool = typer.Option(
370
+ False,
371
+ '--transforms',
372
+ help='List data transforms',
373
+ ),
374
+ ) -> int:
375
+ """
376
+ Inspect a pipeline configuration.
377
+
378
+ Parameters
379
+ ----------
380
+ ctx : typer.Context
381
+ The Typer context.
382
+ config : PipelineConfigOption
383
+ Path to pipeline YAML configuration file.
384
+ jobs : bool, optional
385
+ List available job names and exit. Default is ``False``.
386
+ pipelines : bool, optional
387
+ List ETL pipelines. Default is ``False``.
388
+ sources : bool, optional
389
+ List data sources. Default is ``False``.
390
+ summary : bool, optional
391
+ Show pipeline summary (name, version, sources, targets, jobs). Default
392
+ is ``False``.
393
+ targets : bool, optional
394
+ List data targets. Default is ``False``.
395
+ transforms : bool, optional
396
+ List data transforms. Default is ``False``.
397
+
398
+ Returns
399
+ -------
400
+ int
401
+ Exit code.
402
+
403
+ Raises
404
+ ------
405
+ typer.Exit
406
+ When argument order is invalid or required arguments are missing.
407
+ """
408
+ # Argument order enforcement.
409
+ if not config:
410
+ typer.echo("Error: Missing required option '--config'.", err=True)
411
+ raise typer.Exit(2)
412
+
413
+ state = ensure_state(ctx)
414
+ return int(
415
+ handlers.check_handler(
416
+ config=config,
417
+ jobs=jobs,
418
+ pipelines=pipelines,
419
+ sources=sources,
420
+ summary=summary,
421
+ targets=targets,
422
+ transforms=transforms,
423
+ pretty=state.pretty,
424
+ ),
425
+ )
426
+
427
+
428
+ @app.command('extract')
429
+ def extract_cmd(
430
+ ctx: typer.Context,
431
+ source: SourceArg = '-',
432
+ source_format: SourceFormatOption = None,
433
+ source_type: SourceTypeOption = None,
434
+ ) -> int:
435
+ """
436
+ Extract data from files, databases, or REST APIs.
437
+
438
+ Parameters
439
+ ----------
440
+ ctx : typer.Context
441
+ The Typer context.
442
+ source : SourceArg, optional
443
+ Source (JSON payload, file/folder path, URL/URI, or - for STDIN)
444
+ from which to extract data. Default is ``-``.
445
+ source_format : SourceFormatOption, optional
446
+ Source data format. Overrides the inferred format (``csv``, ``json``,
447
+ ``parquet``, ``xml``) based on filename extension or STDIN content.
448
+ Default is ``None``.
449
+ source_type : SourceTypeOption, optional
450
+ Data source type. Overrides the inferred type (``api``, ``database``,
451
+ ``file``, ``folder``) based on URI/URL schema. Default is ``None``.
452
+
453
+ Returns
454
+ -------
455
+ int
456
+ Exit code.
457
+
458
+ Raises
459
+ ------
460
+ typer.Exit
461
+ When argument order is invalid or required arguments are missing.
462
+ """
463
+ state = ensure_state(ctx)
464
+
465
+ # Argument order enforcement
466
+ if source.startswith('--'):
467
+ typer.echo(
468
+ f"Error: Option '{source}' must follow the 'SOURCE' argument.",
469
+ err=True,
470
+ )
471
+ raise typer.Exit(2)
472
+ if not source:
473
+ typer.echo("Error: Missing required argument 'SOURCE'.", err=True)
474
+ raise typer.Exit(2)
475
+
476
+ source_type = optional_choice(
477
+ source_type,
478
+ DATA_CONNECTORS,
479
+ label='source_type',
480
+ )
481
+ source_format = cast(
482
+ SourceFormatOption,
483
+ optional_choice(
484
+ source_format,
485
+ FILE_FORMATS,
486
+ label='source_format',
487
+ ),
488
+ )
489
+
490
+ resolved_source_type = source_type or infer_resource_type_or_exit(source)
491
+
492
+ log_inferred_resource(
493
+ state,
494
+ role='source',
495
+ value=source,
496
+ resource_type=resolved_source_type,
497
+ )
498
+
499
+ return int(
500
+ handlers.extract_handler(
501
+ source_type=resolved_source_type,
502
+ source=source,
503
+ format_hint=source_format,
504
+ format_explicit=source_format is not None,
505
+ pretty=state.pretty,
506
+ ),
507
+ )
508
+
509
+
510
+ @app.command('load')
511
+ def load_cmd(
512
+ ctx: typer.Context,
513
+ source_format: SourceFormatOption = None,
514
+ target: TargetArg = '-',
515
+ target_format: TargetFormatOption = None,
516
+ target_type: TargetTypeOption = None,
517
+ ) -> int:
518
+ """
519
+ Load data into a file, database, or REST API.
520
+
521
+ Parameters
522
+ ----------
523
+ ctx : typer.Context
524
+ The Typer context.
525
+ source_format : SourceFormatOption, optional
526
+ Source data format. Overrides the inferred format (``csv``, ``json``,
527
+ ``parquet``, ``xml``) based on STDIN content. Default is ``None``.
528
+ target : TargetArg, optional
529
+ Target (file/folder path, URL/URI, or - for STDOUT) into which to load
530
+ data. Default is ``-``.
531
+ target_format : TargetFormatOption, optional
532
+ Format of the target data. Overrides the inferred format (``csv``,
533
+ ``json``, ``parquet``, ``xml``) based on filename extension. Default is
534
+ ``None``.
535
+ target_type : TargetTypeOption, optional
536
+ Data target type. Overrides the inferred type (``api``, ``database``,
537
+ ``file``, ``folder``) based on URI/URL schema. Default is ``None``.
538
+
539
+ Returns
540
+ -------
541
+ int
542
+ Exit code.
543
+
544
+ Raises
545
+ ------
546
+ typer.Exit
547
+ When argument order is invalid or required arguments are missing.
548
+ """
549
+ # Argument order enforcement
550
+ if target.startswith('--'):
551
+ typer.echo(
552
+ f"Error: Option '{target}' must follow the 'TARGET' argument.",
553
+ err=True,
554
+ )
555
+ raise typer.Exit(2)
556
+ if not target:
557
+ typer.echo("Error: Missing required argument 'TARGET'.", err=True)
558
+ raise typer.Exit(2)
559
+
560
+ state = ensure_state(ctx)
561
+
562
+ source_format = cast(
563
+ SourceFormatOption,
564
+ optional_choice(
565
+ source_format,
566
+ FILE_FORMATS,
567
+ label='source_format',
568
+ ),
569
+ )
570
+ target_type = optional_choice(
571
+ target_type,
572
+ DATA_CONNECTORS,
573
+ label='target_type',
574
+ )
575
+ target_format = cast(
576
+ TargetFormatOption,
577
+ optional_choice(
578
+ target_format,
579
+ FILE_FORMATS,
580
+ label='target_format',
581
+ ),
582
+ )
583
+
584
+ resolved_target = target
585
+ resolved_target_type = target_type or infer_resource_type_or_exit(
586
+ resolved_target,
587
+ )
588
+
589
+ resolved_source_value = '-'
590
+ resolved_source_type = infer_resource_type_soft(resolved_source_value)
591
+
592
+ log_inferred_resource(
593
+ state,
594
+ role='source',
595
+ value=resolved_source_value,
596
+ resource_type=resolved_source_type,
597
+ )
598
+ log_inferred_resource(
599
+ state,
600
+ role='target',
601
+ value=resolved_target,
602
+ resource_type=resolved_target_type,
603
+ )
604
+
605
+ return int(
606
+ handlers.load_handler(
607
+ source=resolved_source_value,
608
+ target_type=resolved_target_type,
609
+ target=resolved_target,
610
+ source_format=source_format,
611
+ target_format=target_format,
612
+ format_explicit=target_format is not None,
613
+ output=None,
614
+ pretty=state.pretty,
615
+ ),
616
+ )
617
+
618
+
619
+ @app.command('render')
620
+ def render_cmd(
621
+ ctx: typer.Context,
622
+ config: RenderConfigOption = None,
623
+ spec: RenderSpecOption = None,
624
+ table: RenderTableOption = None,
625
+ template: RenderTemplateOption = 'ddl',
626
+ template_path: RenderTemplatePathOption = None,
627
+ output: OutputOption = None,
628
+ ) -> int:
629
+ """
630
+ Render SQL DDL from table schemas defined in YAML/JSON configs.
631
+
632
+ Parameters
633
+ ----------
634
+ ctx : typer.Context
635
+ The Typer context.
636
+ config : RenderConfigOption
637
+ Pipeline YAML that includes table_schemas for rendering.
638
+ spec : RenderSpecOption, optional
639
+ Standalone table spec file (.yml/.yaml/.json).
640
+ table : RenderTableOption, optional
641
+ Filter to a single table name from table_schemas.
642
+ template : RenderTemplateOption
643
+ Template key (ddl/view) or path to a Jinja template file.
644
+ template_path : RenderTemplatePathOption, optional
645
+ Explicit path to a Jinja template file (overrides template key).
646
+ output : OutputOption, optional
647
+ Path of file to which to write rendered SQL (default: STDOUT).
648
+
649
+ Returns
650
+ -------
651
+ int
652
+ Exit code.
653
+
654
+ Raises
655
+ ------
656
+ typer.Exit
657
+ When argument order is invalid or required arguments are missing.
658
+ """
659
+ # Argument order enforcement
660
+ if not (config or spec):
661
+ typer.echo(
662
+ "Error: Missing required option '--config' or '--spec'.",
663
+ err=True,
664
+ )
665
+ raise typer.Exit(2)
666
+
667
+ state = ensure_state(ctx)
668
+ return int(
669
+ handlers.render_handler(
670
+ config=config,
671
+ spec=spec,
672
+ table=table,
673
+ template=template,
674
+ template_path=template_path,
675
+ output=output,
676
+ pretty=state.pretty,
677
+ quiet=state.quiet,
678
+ ),
679
+ )
680
+
681
+
682
+ @app.command('run')
683
+ def run_cmd(
684
+ ctx: typer.Context,
685
+ config: PipelineConfigOption,
686
+ job: str | None = typer.Option(
687
+ None,
688
+ '-j',
689
+ '--job',
690
+ help='Name of the job to run',
691
+ ),
692
+ pipeline: str | None = typer.Option(
693
+ None,
694
+ '-p',
695
+ '--pipeline',
696
+ help='Name of the pipeline to run',
697
+ ),
698
+ ) -> int:
699
+ """
700
+ Execute an ETL job or pipeline from a YAML configuration.
701
+
702
+ Parameters
703
+ ----------
704
+ ctx : typer.Context
705
+ The Typer context.
706
+ config : PipelineConfigOption
707
+ Path to pipeline YAML configuration file.
708
+ job : str | None, optional
709
+ Name of the job to run. Default is ``None``.
710
+ pipeline : str | None, optional
711
+ Name of the pipeline to run. Default is ``None``.
712
+
713
+ Returns
714
+ -------
715
+ int
716
+ Exit code.
717
+
718
+ Raises
719
+ ------
720
+ typer.Exit
721
+ When argument order is invalid or required arguments are missing.
722
+ """
723
+ # Argument order enforcement
724
+ if not config:
725
+ typer.echo("Error: Missing required option '--config'.", err=True)
726
+ raise typer.Exit(2)
727
+
728
+ state = ensure_state(ctx)
729
+ return int(
730
+ handlers.run_handler(
731
+ config=config,
732
+ job=job,
733
+ pipeline=pipeline,
734
+ pretty=state.pretty,
735
+ ),
736
+ )
737
+
738
+
739
+ @app.command('transform')
740
+ def transform_cmd(
741
+ ctx: typer.Context,
742
+ operations: OperationsOption = '{}',
743
+ source: SourceArg = '-',
744
+ source_format: SourceFormatOption = None,
745
+ source_type: SourceTypeOption = None,
746
+ target: TargetArg = '-',
747
+ target_format: TargetFormatOption = None,
748
+ target_type: TargetTypeOption = None,
749
+ ) -> int:
750
+ """
751
+ Transform records using JSON-described operations.
752
+
753
+ Parameters
754
+ ----------
755
+ ctx : typer.Context
756
+ The Typer context.
757
+ operations : OperationsOption, optional
758
+ Transformation operations as JSON string. Default is ``{}``.
759
+ source : SourceArg, optional
760
+ Source (JSON payload, file/folder path, URL/URI, or - for STDIN) from
761
+ which to extract data. Default is ``-``.
762
+ source_format : SourceFormatOption, optional
763
+ Source data format. Overrides the inferred format (``csv``, ``json``,
764
+ ``parquet``, ``xml``) based on filename extension or STDIN content.
765
+ Default is ``None``.
766
+ source_type : SourceTypeOption, optional
767
+ Data source type. Overrides the inferred type (``api``, ``database``,
768
+ ``file``, ``folder``) based on URI/URL schema. Default is ``None``.
769
+ target : TargetArg, optional
770
+ Target (file/folder path, URL/URI, or - for STDOUT) into which to load
771
+ data. Default is ``-``.
772
+ target_format : TargetFormatOption, optional
773
+ Format of the target data. Overrides the inferred format (``csv``,
774
+ ``json``, ``parquet``, ``xml``) based on filename extension. Default is
775
+ ``None``.
776
+ target_type : TargetTypeOption, optional
777
+ Data target type. Overrides the inferred type (``api``, ``database``,
778
+ ``file``, ``folder``) based on URI/URL schema. Default is ``None``.
779
+
780
+ Returns
781
+ -------
782
+ int
783
+ Exit code.
784
+ """
785
+ state = ensure_state(ctx)
786
+
787
+ source_format = cast(
788
+ SourceFormatOption,
789
+ optional_choice(
790
+ source_format,
791
+ FILE_FORMATS,
792
+ label='source_format',
793
+ ),
794
+ )
795
+ source_type = optional_choice(
796
+ source_type,
797
+ DATA_CONNECTORS,
798
+ label='source_type',
799
+ )
800
+ target_format = cast(
801
+ TargetFormatOption,
802
+ optional_choice(
803
+ target_format,
804
+ FILE_FORMATS,
805
+ label='target_format',
806
+ ),
807
+ )
808
+ target_type = optional_choice(
809
+ target_type,
810
+ DATA_CONNECTORS,
811
+ label='target_type',
812
+ )
813
+
814
+ resolved_source_type = source_type or infer_resource_type_soft(source)
815
+ resolved_source_value = source if source is not None else '-'
816
+ resolved_target_value = target if target is not None else '-'
817
+
818
+ if resolved_source_type is not None:
819
+ resolved_source_type = validate_choice(
820
+ resolved_source_type,
821
+ DATA_CONNECTORS,
822
+ label='source_type',
823
+ )
824
+
825
+ resolved_target_type = resolve_resource_type(
826
+ explicit_type=None,
827
+ override_type=target_type,
828
+ value=resolved_target_value,
829
+ label='target_type',
830
+ )
831
+
832
+ log_inferred_resource(
833
+ state,
834
+ role='source',
835
+ value=resolved_source_value,
836
+ resource_type=resolved_source_type,
837
+ )
838
+ log_inferred_resource(
839
+ state,
840
+ role='target',
841
+ value=resolved_target_value,
842
+ resource_type=resolved_target_type,
843
+ )
844
+
845
+ return int(
846
+ handlers.transform_handler(
847
+ source=resolved_source_value,
848
+ operations=_parse_json_option(operations, '--operations'),
849
+ target=resolved_target_value,
850
+ source_format=source_format,
851
+ target_format=target_format,
852
+ format_explicit=target_format is not None,
853
+ pretty=state.pretty,
854
+ ),
855
+ )
856
+
857
+
858
+ @app.command('validate')
859
+ def validate_cmd(
860
+ ctx: typer.Context,
861
+ rules: RulesOption = '{}',
862
+ source: SourceArg = '-',
863
+ source_format: SourceFormatOption = None,
864
+ source_type: SourceTypeOption = None,
865
+ output: OutputOption = '-',
866
+ ) -> int:
867
+ """
868
+ Validate data against JSON-described rules.
869
+
870
+ Parameters
871
+ ----------
872
+ ctx : typer.Context
873
+ The Typer context.
874
+ rules : RulesOption
875
+ Validation rules as JSON string.
876
+ source : SourceArg
877
+ Data source to validate (path, JSON payload, or - for STDIN).
878
+ source_format : SourceFormatOption, optional
879
+ Format of the source. Overrides filename-based inference when provided.
880
+ Default is ``None``.
881
+ source_type : SourceTypeOption, optional
882
+ Override the inferred source type (file, database, api). Default is
883
+ ``None``.
884
+ output : OutputOption, optional
885
+ Output file for validated output (- for STDOUT). Default is ``None``.
886
+
887
+ Returns
888
+ -------
889
+ int
890
+ Exit code.
891
+ """
892
+ source_format = cast(
893
+ SourceFormatOption,
894
+ optional_choice(
895
+ source_format,
896
+ FILE_FORMATS,
897
+ label='source_format',
898
+ ),
899
+ )
900
+ source_type = optional_choice(
901
+ source_type,
902
+ DATA_CONNECTORS,
903
+ label='source_type',
904
+ )
905
+ state = ensure_state(ctx)
906
+ resolved_source_type = source_type or infer_resource_type_soft(source)
907
+
908
+ log_inferred_resource(
909
+ state,
910
+ role='source',
911
+ value=source,
912
+ resource_type=resolved_source_type,
913
+ )
914
+
915
+ return int(
916
+ handlers.validate_handler(
917
+ source=source,
918
+ rules=_parse_json_option(rules, '--rules'),
919
+ source_format=source_format,
920
+ target=output,
921
+ format_explicit=source_format is not None,
922
+ pretty=state.pretty,
923
+ ),
924
+ )