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