etlplus 0.8.0__py3-none-any.whl → 0.8.2__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/cli/commands.py +645 -0
- etlplus/cli/constants.py +65 -0
- etlplus/cli/handlers.py +34 -311
- etlplus/cli/io.py +343 -0
- etlplus/cli/main.py +46 -108
- etlplus/cli/options.py +115 -0
- etlplus/cli/state.py +411 -0
- etlplus/cli/types.py +33 -0
- {etlplus-0.8.0.dist-info → etlplus-0.8.2.dist-info}/METADATA +1 -1
- {etlplus-0.8.0.dist-info → etlplus-0.8.2.dist-info}/RECORD +14 -9
- etlplus/cli/app.py +0 -1312
- {etlplus-0.8.0.dist-info → etlplus-0.8.2.dist-info}/WHEEL +0 -0
- {etlplus-0.8.0.dist-info → etlplus-0.8.2.dist-info}/entry_points.txt +0 -0
- {etlplus-0.8.0.dist-info → etlplus-0.8.2.dist-info}/licenses/LICENSE +0 -0
- {etlplus-0.8.0.dist-info → etlplus-0.8.2.dist-info}/top_level.txt +0 -0
etlplus/cli/commands.py
ADDED
|
@@ -0,0 +1,645 @@
|
|
|
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
|
+
|
|
33
|
+
import typer
|
|
34
|
+
|
|
35
|
+
from .. import __version__
|
|
36
|
+
from ..utils import json_type
|
|
37
|
+
from . import handlers
|
|
38
|
+
from .constants import CLI_DESCRIPTION
|
|
39
|
+
from .constants import CLI_EPILOG
|
|
40
|
+
from .constants import DATA_CONNECTORS
|
|
41
|
+
from .constants import DEFAULT_FILE_FORMAT
|
|
42
|
+
from .constants import FILE_FORMATS
|
|
43
|
+
from .options import typer_format_option_kwargs
|
|
44
|
+
from .state import CliState
|
|
45
|
+
from .state import ensure_state
|
|
46
|
+
from .state import format_namespace_kwargs
|
|
47
|
+
from .state import infer_resource_type_or_exit
|
|
48
|
+
from .state import infer_resource_type_soft
|
|
49
|
+
from .state import log_inferred_resource
|
|
50
|
+
from .state import optional_choice
|
|
51
|
+
from .state import resolve_resource_type
|
|
52
|
+
from .state import stateful_namespace
|
|
53
|
+
from .state import validate_choice
|
|
54
|
+
|
|
55
|
+
# SECTION: EXPORTS ========================================================== #
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
__all__ = ['app']
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# SECTION: TYPE ALIASES ==================================================== #
|
|
62
|
+
|
|
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
|
+
str,
|
|
123
|
+
typer.Option(
|
|
124
|
+
'--template',
|
|
125
|
+
'-t',
|
|
126
|
+
metavar='KEY|PATH',
|
|
127
|
+
help='Template key (ddl/view) or path to a Jinja template file.',
|
|
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
|
+
str | 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
|
+
str | 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
|
+
str | 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: TYPER APP ======================================================== #
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
app = typer.Typer(
|
|
248
|
+
name='etlplus',
|
|
249
|
+
help=CLI_DESCRIPTION,
|
|
250
|
+
epilog=CLI_EPILOG,
|
|
251
|
+
add_completion=True,
|
|
252
|
+
no_args_is_help=False,
|
|
253
|
+
rich_markup_mode='markdown',
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@app.callback(invoke_without_command=True)
|
|
258
|
+
def _root(
|
|
259
|
+
ctx: typer.Context,
|
|
260
|
+
version: bool = typer.Option(
|
|
261
|
+
False,
|
|
262
|
+
'--version',
|
|
263
|
+
'-V',
|
|
264
|
+
is_eager=True,
|
|
265
|
+
help='Show the version and exit.',
|
|
266
|
+
),
|
|
267
|
+
pretty: bool = typer.Option(
|
|
268
|
+
True,
|
|
269
|
+
'--pretty/--no-pretty',
|
|
270
|
+
help='Pretty-print JSON output (default: pretty).',
|
|
271
|
+
),
|
|
272
|
+
quiet: bool = typer.Option(
|
|
273
|
+
False,
|
|
274
|
+
'--quiet',
|
|
275
|
+
'-q',
|
|
276
|
+
help='Suppress warnings and non-essential output.',
|
|
277
|
+
),
|
|
278
|
+
verbose: bool = typer.Option(
|
|
279
|
+
False,
|
|
280
|
+
'--verbose',
|
|
281
|
+
'-v',
|
|
282
|
+
help='Emit extra diagnostics to stderr.',
|
|
283
|
+
),
|
|
284
|
+
) -> None:
|
|
285
|
+
"""
|
|
286
|
+
Seed the Typer context with runtime flags and handle root-only options.
|
|
287
|
+
"""
|
|
288
|
+
ctx.obj = CliState(pretty=pretty, quiet=quiet, verbose=verbose)
|
|
289
|
+
|
|
290
|
+
if version:
|
|
291
|
+
typer.echo(f'etlplus {__version__}')
|
|
292
|
+
raise typer.Exit(0)
|
|
293
|
+
|
|
294
|
+
if ctx.invoked_subcommand is None and not ctx.resilient_parsing:
|
|
295
|
+
typer.echo(ctx.command.get_help(ctx))
|
|
296
|
+
raise typer.Exit(0)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
@app.command('check')
|
|
300
|
+
def check_cmd(
|
|
301
|
+
ctx: typer.Context,
|
|
302
|
+
config: PipelineConfigOption,
|
|
303
|
+
jobs: bool = typer.Option(
|
|
304
|
+
False,
|
|
305
|
+
'--jobs',
|
|
306
|
+
help='List available job names and exit',
|
|
307
|
+
),
|
|
308
|
+
pipelines: bool = typer.Option(
|
|
309
|
+
False,
|
|
310
|
+
'--pipelines',
|
|
311
|
+
help='List ETL pipelines',
|
|
312
|
+
),
|
|
313
|
+
sources: bool = typer.Option(
|
|
314
|
+
False,
|
|
315
|
+
'--sources',
|
|
316
|
+
help='List data sources',
|
|
317
|
+
),
|
|
318
|
+
summary: bool = typer.Option(
|
|
319
|
+
False,
|
|
320
|
+
'--summary',
|
|
321
|
+
help='Show pipeline summary (name, version, sources, targets, jobs)',
|
|
322
|
+
),
|
|
323
|
+
targets: bool = typer.Option(
|
|
324
|
+
False,
|
|
325
|
+
'--targets',
|
|
326
|
+
help='List data targets',
|
|
327
|
+
),
|
|
328
|
+
transforms: bool = typer.Option(
|
|
329
|
+
False,
|
|
330
|
+
'--transforms',
|
|
331
|
+
help='List data transforms',
|
|
332
|
+
),
|
|
333
|
+
) -> int:
|
|
334
|
+
"""Inspect a pipeline configuration."""
|
|
335
|
+
state = ensure_state(ctx)
|
|
336
|
+
ns = stateful_namespace(
|
|
337
|
+
state,
|
|
338
|
+
command='check',
|
|
339
|
+
config=config,
|
|
340
|
+
jobs=jobs,
|
|
341
|
+
pipelines=pipelines,
|
|
342
|
+
sources=sources,
|
|
343
|
+
summary=summary,
|
|
344
|
+
targets=targets,
|
|
345
|
+
transforms=transforms,
|
|
346
|
+
)
|
|
347
|
+
return int(handlers.check_handler(ns))
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
@app.command('extract')
|
|
351
|
+
def extract_cmd(
|
|
352
|
+
ctx: typer.Context,
|
|
353
|
+
source: SourceInputArg,
|
|
354
|
+
source_format: SourceFormatOption | None = None,
|
|
355
|
+
source_type: SourceOverrideOption | None = None,
|
|
356
|
+
) -> int:
|
|
357
|
+
"""Extract data from files, databases, or REST APIs."""
|
|
358
|
+
state = ensure_state(ctx)
|
|
359
|
+
|
|
360
|
+
source_type = optional_choice(
|
|
361
|
+
source_type,
|
|
362
|
+
DATA_CONNECTORS,
|
|
363
|
+
label='source_type',
|
|
364
|
+
)
|
|
365
|
+
source_format = optional_choice(
|
|
366
|
+
source_format,
|
|
367
|
+
FILE_FORMATS,
|
|
368
|
+
label='source_format',
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
resolved_source = source
|
|
372
|
+
resolved_source_type = source_type or infer_resource_type_or_exit(
|
|
373
|
+
resolved_source,
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
log_inferred_resource(
|
|
377
|
+
state,
|
|
378
|
+
role='source',
|
|
379
|
+
value=resolved_source,
|
|
380
|
+
resource_type=resolved_source_type,
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
format_kwargs = format_namespace_kwargs(
|
|
384
|
+
format_value=source_format,
|
|
385
|
+
default=DEFAULT_FILE_FORMAT,
|
|
386
|
+
)
|
|
387
|
+
ns = stateful_namespace(
|
|
388
|
+
state,
|
|
389
|
+
command='extract',
|
|
390
|
+
source_type=resolved_source_type,
|
|
391
|
+
source=resolved_source,
|
|
392
|
+
**format_kwargs,
|
|
393
|
+
)
|
|
394
|
+
return int(handlers.extract_handler(ns))
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
@app.command('load')
|
|
398
|
+
def load_cmd(
|
|
399
|
+
ctx: typer.Context,
|
|
400
|
+
target: TargetInputArg,
|
|
401
|
+
source_format: StdinFormatOption | None = None,
|
|
402
|
+
target_format: TargetFormatOption | None = None,
|
|
403
|
+
target_type: TargetOverrideOption | None = None,
|
|
404
|
+
) -> int:
|
|
405
|
+
"""Load data into a file, database, or REST API."""
|
|
406
|
+
state = ensure_state(ctx)
|
|
407
|
+
|
|
408
|
+
source_format = optional_choice(
|
|
409
|
+
source_format,
|
|
410
|
+
FILE_FORMATS,
|
|
411
|
+
label='source_format',
|
|
412
|
+
)
|
|
413
|
+
target_type = optional_choice(
|
|
414
|
+
target_type,
|
|
415
|
+
DATA_CONNECTORS,
|
|
416
|
+
label='target_type',
|
|
417
|
+
)
|
|
418
|
+
target_format = optional_choice(
|
|
419
|
+
target_format,
|
|
420
|
+
FILE_FORMATS,
|
|
421
|
+
label='target_format',
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
resolved_target = target
|
|
425
|
+
resolved_target_type = target_type or infer_resource_type_or_exit(
|
|
426
|
+
resolved_target,
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
resolved_source_value = '-'
|
|
430
|
+
resolved_source_type = infer_resource_type_soft(resolved_source_value)
|
|
431
|
+
|
|
432
|
+
log_inferred_resource(
|
|
433
|
+
state,
|
|
434
|
+
role='source',
|
|
435
|
+
value=resolved_source_value,
|
|
436
|
+
resource_type=resolved_source_type,
|
|
437
|
+
)
|
|
438
|
+
log_inferred_resource(
|
|
439
|
+
state,
|
|
440
|
+
role='target',
|
|
441
|
+
value=resolved_target,
|
|
442
|
+
resource_type=resolved_target_type,
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
format_kwargs = format_namespace_kwargs(
|
|
446
|
+
format_value=target_format,
|
|
447
|
+
default=DEFAULT_FILE_FORMAT,
|
|
448
|
+
)
|
|
449
|
+
ns = stateful_namespace(
|
|
450
|
+
state,
|
|
451
|
+
command='load',
|
|
452
|
+
source=resolved_source_value,
|
|
453
|
+
source_format=source_format,
|
|
454
|
+
target_type=resolved_target_type,
|
|
455
|
+
target=resolved_target,
|
|
456
|
+
**format_kwargs,
|
|
457
|
+
)
|
|
458
|
+
return int(handlers.load_handler(ns))
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
@app.command('render')
|
|
462
|
+
def render_cmd(
|
|
463
|
+
ctx: typer.Context,
|
|
464
|
+
config: RenderConfigOption = None,
|
|
465
|
+
spec: RenderSpecOption = None,
|
|
466
|
+
table: RenderTableOption = None,
|
|
467
|
+
template: RenderTemplateOption = 'ddl',
|
|
468
|
+
template_path: RenderTemplatePathOption = None,
|
|
469
|
+
output: RenderOutputOption = None,
|
|
470
|
+
) -> int:
|
|
471
|
+
"""Render SQL DDL from table schemas defined in YAML/JSON configs."""
|
|
472
|
+
state = ensure_state(ctx)
|
|
473
|
+
ns = stateful_namespace(
|
|
474
|
+
state,
|
|
475
|
+
command='render',
|
|
476
|
+
config=config,
|
|
477
|
+
spec=spec,
|
|
478
|
+
table=table,
|
|
479
|
+
template=template,
|
|
480
|
+
template_path=template_path,
|
|
481
|
+
output=output,
|
|
482
|
+
)
|
|
483
|
+
return int(handlers.render_handler(ns))
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
@app.command('run')
|
|
487
|
+
def run_cmd(
|
|
488
|
+
ctx: typer.Context,
|
|
489
|
+
config: PipelineConfigOption,
|
|
490
|
+
job: str | None = typer.Option(
|
|
491
|
+
None,
|
|
492
|
+
'-j',
|
|
493
|
+
'--job',
|
|
494
|
+
help='Name of the job to run',
|
|
495
|
+
),
|
|
496
|
+
pipeline: str | None = typer.Option(
|
|
497
|
+
None,
|
|
498
|
+
'-p',
|
|
499
|
+
'--pipeline',
|
|
500
|
+
help='Name of the pipeline to run',
|
|
501
|
+
),
|
|
502
|
+
) -> int:
|
|
503
|
+
"""Execute an ETL job or pipeline from a YAML configuration."""
|
|
504
|
+
state = ensure_state(ctx)
|
|
505
|
+
ns = stateful_namespace(
|
|
506
|
+
state,
|
|
507
|
+
command='run',
|
|
508
|
+
config=config,
|
|
509
|
+
job=job,
|
|
510
|
+
pipeline=pipeline,
|
|
511
|
+
)
|
|
512
|
+
return int(handlers.run_handler(ns))
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
@app.command('transform')
|
|
516
|
+
def transform_cmd(
|
|
517
|
+
ctx: typer.Context,
|
|
518
|
+
operations: OperationsOption = '{}',
|
|
519
|
+
source: StreamingSourceArg = '-',
|
|
520
|
+
source_format: SourceFormatOption | None = None,
|
|
521
|
+
source_type: SourceOverrideOption | None = None,
|
|
522
|
+
target: TargetPathOption | None = None,
|
|
523
|
+
target_format: TargetFormatOption | None = None,
|
|
524
|
+
target_type: TargetOverrideOption | None = None,
|
|
525
|
+
) -> int:
|
|
526
|
+
"""Transform records using JSON-described operations."""
|
|
527
|
+
state = ensure_state(ctx)
|
|
528
|
+
|
|
529
|
+
source_format = optional_choice(
|
|
530
|
+
source_format,
|
|
531
|
+
FILE_FORMATS,
|
|
532
|
+
label='source_format',
|
|
533
|
+
)
|
|
534
|
+
source_type = optional_choice(
|
|
535
|
+
source_type,
|
|
536
|
+
DATA_CONNECTORS,
|
|
537
|
+
label='source_type',
|
|
538
|
+
)
|
|
539
|
+
target_format = optional_choice(
|
|
540
|
+
target_format,
|
|
541
|
+
FILE_FORMATS,
|
|
542
|
+
label='target_format',
|
|
543
|
+
)
|
|
544
|
+
target_format_kwargs = format_namespace_kwargs(
|
|
545
|
+
format_value=target_format,
|
|
546
|
+
default=DEFAULT_FILE_FORMAT,
|
|
547
|
+
)
|
|
548
|
+
target_type = optional_choice(
|
|
549
|
+
target_type,
|
|
550
|
+
DATA_CONNECTORS,
|
|
551
|
+
label='target_type',
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
resolved_source_type = source_type or infer_resource_type_soft(source)
|
|
555
|
+
resolved_source_value = source if source is not None else '-'
|
|
556
|
+
resolved_target_value = target if target is not None else '-'
|
|
557
|
+
|
|
558
|
+
if resolved_source_type is not None:
|
|
559
|
+
resolved_source_type = validate_choice(
|
|
560
|
+
resolved_source_type,
|
|
561
|
+
DATA_CONNECTORS,
|
|
562
|
+
label='source_type',
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
resolved_target_type = resolve_resource_type(
|
|
566
|
+
explicit_type=None,
|
|
567
|
+
override_type=target_type,
|
|
568
|
+
value=resolved_target_value,
|
|
569
|
+
label='target_type',
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
log_inferred_resource(
|
|
573
|
+
state,
|
|
574
|
+
role='source',
|
|
575
|
+
value=resolved_source_value,
|
|
576
|
+
resource_type=resolved_source_type,
|
|
577
|
+
)
|
|
578
|
+
log_inferred_resource(
|
|
579
|
+
state,
|
|
580
|
+
role='target',
|
|
581
|
+
value=resolved_target_value,
|
|
582
|
+
resource_type=resolved_target_type,
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
ns = stateful_namespace(
|
|
586
|
+
state,
|
|
587
|
+
command='transform',
|
|
588
|
+
source=resolved_source_value,
|
|
589
|
+
source_type=resolved_source_type,
|
|
590
|
+
operations=json_type(operations),
|
|
591
|
+
target=resolved_target_value,
|
|
592
|
+
source_format=source_format,
|
|
593
|
+
target_type=resolved_target_type,
|
|
594
|
+
target_format=target_format_kwargs['format'],
|
|
595
|
+
**target_format_kwargs,
|
|
596
|
+
)
|
|
597
|
+
return int(handlers.transform_handler(ns))
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
@app.command('validate')
|
|
601
|
+
def validate_cmd(
|
|
602
|
+
ctx: typer.Context,
|
|
603
|
+
rules: RulesOption = '{}',
|
|
604
|
+
source: StreamingSourceArg = '-',
|
|
605
|
+
source_format: SourceFormatOption | None = None,
|
|
606
|
+
source_type: SourceOverrideOption | None = None,
|
|
607
|
+
target: TargetPathOption | None = None,
|
|
608
|
+
) -> int:
|
|
609
|
+
"""Validate data against JSON-described rules."""
|
|
610
|
+
source_format = optional_choice(
|
|
611
|
+
source_format,
|
|
612
|
+
FILE_FORMATS,
|
|
613
|
+
label='source_format',
|
|
614
|
+
)
|
|
615
|
+
source_type = optional_choice(
|
|
616
|
+
source_type,
|
|
617
|
+
DATA_CONNECTORS,
|
|
618
|
+
label='source_type',
|
|
619
|
+
)
|
|
620
|
+
source_format_kwargs = format_namespace_kwargs(
|
|
621
|
+
format_value=source_format,
|
|
622
|
+
default=DEFAULT_FILE_FORMAT,
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
state = ensure_state(ctx)
|
|
626
|
+
resolved_source_type = source_type or infer_resource_type_soft(source)
|
|
627
|
+
|
|
628
|
+
log_inferred_resource(
|
|
629
|
+
state,
|
|
630
|
+
role='source',
|
|
631
|
+
value=source,
|
|
632
|
+
resource_type=resolved_source_type,
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
ns = stateful_namespace(
|
|
636
|
+
state,
|
|
637
|
+
command='validate',
|
|
638
|
+
source=source,
|
|
639
|
+
source_type=resolved_source_type,
|
|
640
|
+
rules=json_type(rules), # convert CLI string to dict
|
|
641
|
+
target=target,
|
|
642
|
+
source_format=source_format,
|
|
643
|
+
**source_format_kwargs,
|
|
644
|
+
)
|
|
645
|
+
return int(handlers.validate_handler(ns))
|
etlplus/cli/constants.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""
|
|
2
|
+
:mod:`etlplus.cli.constants` module.
|
|
3
|
+
|
|
4
|
+
Shared constants for :mod:`etlplus.cli`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Final
|
|
10
|
+
|
|
11
|
+
from ..enums import DataConnectorType
|
|
12
|
+
from ..enums import FileFormat
|
|
13
|
+
|
|
14
|
+
# SECTION: EXPORTS ========================================================== #
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
# Constants
|
|
19
|
+
'CLI_DESCRIPTION',
|
|
20
|
+
'CLI_EPILOG',
|
|
21
|
+
'DATA_CONNECTORS',
|
|
22
|
+
'DEFAULT_FILE_FORMAT',
|
|
23
|
+
'FILE_FORMATS',
|
|
24
|
+
'PROJECT_URL',
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# SECTION: CONSTANTS ======================================================== #
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
DATA_CONNECTORS: Final[frozenset[str]] = frozenset(DataConnectorType.choices())
|
|
32
|
+
|
|
33
|
+
FILE_FORMATS: Final[frozenset[str]] = frozenset(FileFormat.choices())
|
|
34
|
+
DEFAULT_FILE_FORMAT: Final[str] = 'json'
|
|
35
|
+
|
|
36
|
+
CLI_DESCRIPTION: Final[str] = '\n'.join(
|
|
37
|
+
[
|
|
38
|
+
'ETLPlus - A Swiss Army knife for simple ETL operations.',
|
|
39
|
+
'',
|
|
40
|
+
' Provide a subcommand and options. Examples:',
|
|
41
|
+
'',
|
|
42
|
+
' etlplus extract in.csv > out.json',
|
|
43
|
+
' etlplus validate in.json --rules "{"required": ["id"]}"',
|
|
44
|
+
(
|
|
45
|
+
' etlplus transform --from file in.json '
|
|
46
|
+
'--operations "{"select": ["id"]}" --to file -o out.json'
|
|
47
|
+
),
|
|
48
|
+
' etlplus extract in.csv | etlplus load --to file out.json',
|
|
49
|
+
' cat data.json | etlplus load --to api https://example.com/data',
|
|
50
|
+
'',
|
|
51
|
+
' Override format inference when extensions are misleading:',
|
|
52
|
+
'',
|
|
53
|
+
' etlplus extract data.txt --source-format csv',
|
|
54
|
+
' etlplus load payload.bin --target-format json',
|
|
55
|
+
],
|
|
56
|
+
)
|
|
57
|
+
CLI_EPILOG: Final[str] = '\n'.join(
|
|
58
|
+
[
|
|
59
|
+
'Tip:',
|
|
60
|
+
'--source-format and --target-format override format inference '
|
|
61
|
+
'based on filename extensions when needed.',
|
|
62
|
+
],
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
PROJECT_URL: Final[str] = 'https://github.com/Dagitali/ETLPlus'
|