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/io.py ADDED
@@ -0,0 +1,343 @@
1
+ """
2
+ :mod:`etlplus.cli.io` module.
3
+
4
+ Shared I/O helpers for CLI handlers (stdin/stdout, payload hydration).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import csv
11
+ import io as _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 ..enums import FileFormat
20
+ from ..file import File
21
+ from ..types import JSONData
22
+ from ..utils import json_type
23
+ from ..utils import print_json
24
+
25
+ # SECTION: EXPORTS ========================================================== #
26
+
27
+
28
+ __all__ = [
29
+ # Functions
30
+ 'emit_json',
31
+ 'emit_or_write',
32
+ 'explicit_cli_format',
33
+ 'infer_payload_format',
34
+ 'materialize_file_payload',
35
+ 'parse_text_payload',
36
+ 'presentation_flags',
37
+ 'read_csv_rows',
38
+ 'read_stdin_text',
39
+ 'resolve_cli_payload',
40
+ 'write_json_output',
41
+ ]
42
+
43
+
44
+ # SECTION: FUNCTIONS ======================================================== #
45
+
46
+
47
+ def emit_json(
48
+ data: Any,
49
+ *,
50
+ pretty: bool,
51
+ ) -> None:
52
+ """
53
+ Emit JSON honoring pretty/compact preference.
54
+
55
+ Parameters
56
+ ----------
57
+ data : Any
58
+ Data to serialize as JSON.
59
+ pretty : bool
60
+ Whether to pretty-print JSON output.
61
+ """
62
+ if pretty:
63
+ print_json(data)
64
+ return
65
+ dumped = json.dumps(data, ensure_ascii=False, separators=(',', ':'))
66
+ print(dumped)
67
+
68
+
69
+ def emit_or_write(
70
+ data: Any,
71
+ output_path: str | None,
72
+ *,
73
+ pretty: bool,
74
+ success_message: str,
75
+ ) -> None:
76
+ """
77
+ Emit JSON or persist to disk based on ``output_path``.
78
+
79
+ Parameters
80
+ ----------
81
+ data : Any
82
+ The data to serialize.
83
+ output_path : str | None
84
+ Target file path; when falsy or ``'-'`` data is emitted to stdout.
85
+ pretty : bool
86
+ Whether to pretty-print JSON emission.
87
+ success_message : str
88
+ Message printed when writing to disk succeeds.
89
+ """
90
+ if write_json_output(
91
+ data,
92
+ output_path,
93
+ success_message=success_message,
94
+ ):
95
+ return
96
+ emit_json(data, pretty=pretty)
97
+
98
+
99
+ def explicit_cli_format(
100
+ args: argparse.Namespace,
101
+ ) -> str | None:
102
+ """
103
+ Return explicit format hint when provided on CLI.
104
+
105
+ Parameters
106
+ ----------
107
+ args : argparse.Namespace
108
+ The argparse namespace containing CLI arguments.
109
+
110
+ Returns
111
+ -------
112
+ str | None
113
+ The explicit format hint if provided, otherwise None.
114
+ """
115
+ if not getattr(args, '_format_explicit', False):
116
+ return None
117
+ for attr in ('format', 'target_format', 'source_format'):
118
+ value = getattr(args, attr, None)
119
+ if value is None:
120
+ continue
121
+ normalized = str(value).strip().lower()
122
+ if normalized:
123
+ return normalized
124
+ return None
125
+
126
+
127
+ def infer_payload_format(
128
+ text: str,
129
+ ) -> str:
130
+ """
131
+ Infer JSON vs CSV from payload text.
132
+
133
+ Parameters
134
+ ----------
135
+ text : str
136
+ The payload text to analyze.
137
+
138
+ Returns
139
+ -------
140
+ str
141
+ The inferred format: either 'json' or 'csv'.
142
+ """
143
+ stripped = text.lstrip()
144
+ if stripped.startswith('{') or stripped.startswith('['):
145
+ return 'json'
146
+ return 'csv'
147
+
148
+
149
+ def materialize_file_payload(
150
+ source: object,
151
+ *,
152
+ format_hint: str | None,
153
+ format_explicit: bool,
154
+ ) -> JSONData | object:
155
+ """
156
+ Return structured payloads when ``source`` references a file.
157
+
158
+ Parameters
159
+ ----------
160
+ source : object
161
+ The source payload, potentially a file path.
162
+ format_hint : str | None
163
+ An optional format hint (e.g., 'json', 'csv').
164
+ format_explicit : bool
165
+ Whether the format hint was explicitly provided.
166
+
167
+ Returns
168
+ -------
169
+ JSONData | object
170
+ The materialized payload if a file was read, otherwise the original
171
+ source.
172
+ """
173
+ if isinstance(source, (dict, list)):
174
+ return cast(JSONData, source)
175
+ if not isinstance(source, (str, os.PathLike)):
176
+ return source
177
+
178
+ path = Path(source)
179
+
180
+ normalized_hint = (format_hint or '').strip().lower()
181
+ fmt: FileFormat | None = None
182
+
183
+ if format_explicit and normalized_hint:
184
+ try:
185
+ fmt = FileFormat(normalized_hint)
186
+ except ValueError:
187
+ fmt = None
188
+ elif not format_explicit:
189
+ suffix = path.suffix.lower().lstrip('.')
190
+ if suffix:
191
+ try:
192
+ fmt = FileFormat(suffix)
193
+ except ValueError:
194
+ fmt = None
195
+
196
+ if fmt is None:
197
+ return source
198
+ if fmt == FileFormat.CSV:
199
+ return read_csv_rows(path)
200
+ return File(path, fmt).read()
201
+
202
+
203
+ def parse_text_payload(
204
+ text: str,
205
+ fmt: str | None,
206
+ ) -> JSONData | str:
207
+ """
208
+ Parse JSON/CSV text into a Python payload.
209
+
210
+ Parameters
211
+ ----------
212
+ text : str
213
+ The text payload to parse.
214
+ fmt : str | None
215
+ An optional format hint (e.g., 'json', 'csv').
216
+
217
+ Returns
218
+ -------
219
+ JSONData | str
220
+ The parsed payload as JSON data or raw text.
221
+ """
222
+ effective = (fmt or '').strip().lower() or infer_payload_format(text)
223
+ if effective == 'json':
224
+ return cast(JSONData, json_type(text))
225
+ if effective == 'csv':
226
+ reader = csv.DictReader(_io.StringIO(text))
227
+ return [dict(row) for row in reader]
228
+ return text
229
+
230
+
231
+ def presentation_flags(
232
+ args: argparse.Namespace,
233
+ ) -> tuple[bool, bool]:
234
+ """
235
+ Return (pretty, quiet) toggles with safe defaults.
236
+
237
+ Parameters
238
+ ----------
239
+ args : argparse.Namespace
240
+ The argparse namespace containing CLI arguments.
241
+
242
+ Returns
243
+ -------
244
+ tuple[bool, bool]
245
+ A tuple containing the pretty and quiet flags.
246
+ """
247
+ return getattr(args, 'pretty', True), getattr(args, 'quiet', False)
248
+
249
+
250
+ def read_csv_rows(
251
+ path: Path,
252
+ ) -> list[dict[str, str]]:
253
+ """
254
+ Read CSV rows into dictionaries.
255
+
256
+ Parameters
257
+ ----------
258
+ path : Path
259
+ The path to the CSV file.
260
+
261
+ Returns
262
+ -------
263
+ list[dict[str, str]]
264
+ The list of CSV rows as dictionaries.
265
+ """
266
+ with path.open(newline='', encoding='utf-8') as handle:
267
+ reader = csv.DictReader(handle)
268
+ return [dict(row) for row in reader]
269
+
270
+
271
+ def read_stdin_text() -> str:
272
+ """Return entire stdin payload."""
273
+ return sys.stdin.read()
274
+
275
+
276
+ def resolve_cli_payload(
277
+ source: object,
278
+ *,
279
+ format_hint: str | None,
280
+ format_explicit: bool,
281
+ hydrate_files: bool = True,
282
+ ) -> JSONData | object:
283
+ """
284
+ Normalize CLI-provided payloads, honoring stdin and inline data.
285
+
286
+ Parameters
287
+ ----------
288
+ source : object
289
+ The source payload, potentially stdin or a file path.
290
+ format_hint : str | None
291
+ An optional format hint (e.g., 'json', 'csv').
292
+ format_explicit : bool
293
+ Whether the format hint was explicitly provided.
294
+ hydrate_files : bool, optional
295
+ Whether to materialize file-based payloads. Default is True.
296
+
297
+ Returns
298
+ -------
299
+ JSONData | object
300
+ The resolved payload.
301
+ """
302
+ if isinstance(source, (os.PathLike, str)) and str(source) == '-':
303
+ text = read_stdin_text()
304
+ return parse_text_payload(text, format_hint)
305
+
306
+ if not hydrate_files:
307
+ return source
308
+
309
+ return materialize_file_payload(
310
+ source,
311
+ format_hint=format_hint,
312
+ format_explicit=format_explicit,
313
+ )
314
+
315
+
316
+ def write_json_output(
317
+ data: Any,
318
+ output_path: str | None,
319
+ *,
320
+ success_message: str,
321
+ ) -> bool:
322
+ """
323
+ Persist JSON data to disk when output path provided.
324
+
325
+ Parameters
326
+ ----------
327
+ data : Any
328
+ The data to serialize as JSON.
329
+ output_path : str | None
330
+ The output file path, or None/'-' to skip writing.
331
+ success_message : str
332
+ The message to print upon successful write.
333
+
334
+ Returns
335
+ -------
336
+ bool
337
+ True if data was written to disk; False if not.
338
+ """
339
+ if not output_path or output_path == '-':
340
+ return False
341
+ File(Path(output_path), FileFormat.JSON).write_json(data)
342
+ print(f'{success_message} {output_path}')
343
+ return True
etlplus/cli/main.py CHANGED
@@ -12,25 +12,21 @@ from __future__ import annotations
12
12
  import argparse
13
13
  import contextlib
14
14
  import sys
15
- from collections.abc import Sequence
16
- from typing import Literal
17
15
 
18
16
  import click
19
17
  import typer
20
18
 
21
19
  from .. import __version__
22
- from ..enums import DataConnectorType
23
- from ..enums import FileFormat
24
20
  from ..utils import json_type
25
- from .app import PROJECT_URL
26
- from .app import app
27
- from .handlers import check_handler
28
- from .handlers import extract_handler
29
- from .handlers import load_handler
30
- from .handlers import render_handler
31
- from .handlers import run_handler
32
- from .handlers import transform_handler
33
- from .handlers import validate_handler
21
+ from . import handlers
22
+ from .commands import app
23
+ from .constants import CLI_DESCRIPTION
24
+ from .constants import CLI_EPILOG
25
+ from .constants import DATA_CONNECTORS
26
+ from .constants import FILE_FORMATS
27
+ from .constants import PROJECT_URL
28
+ from .options import add_argparse_format_options
29
+ from .types import DataConnectorContext
34
30
 
35
31
  # SECTION: EXPORTS ========================================================== #
36
32
 
@@ -42,31 +38,6 @@ __all__ = [
42
38
  ]
43
39
 
44
40
 
45
- # SECTION: TYPE ALIASES ===================================================== #
46
-
47
-
48
- type FormatContext = Literal['source', 'target']
49
-
50
-
51
- # SECTION: INTERNAL CLASSES ================================================= #
52
-
53
-
54
- class _FormatAction(argparse.Action):
55
- """
56
- Argparse action that records when ``--source-format`` or
57
- ``--target-format`` is provided."""
58
-
59
- def __call__(
60
- self,
61
- parser: argparse.ArgumentParser,
62
- namespace: argparse.Namespace,
63
- values: str | Sequence[object] | None,
64
- option_string: str | None = None,
65
- ) -> None: # pragma: no cover
66
- setattr(namespace, self.dest, values)
67
- namespace._format_explicit = True
68
-
69
-
70
41
  # SECTION: INTERNAL FUNCTIONS =============================================== #
71
42
 
72
43
 
@@ -121,7 +92,7 @@ def _add_config_option(
121
92
  def _add_format_options(
122
93
  parser: argparse.ArgumentParser,
123
94
  *,
124
- context: FormatContext,
95
+ context: DataConnectorContext,
125
96
  ) -> None:
126
97
  """
127
98
  Attach shared ``--source-format`` or ``--target-format`` options to
@@ -131,63 +102,11 @@ def _add_format_options(
131
102
  ----------
132
103
  parser : argparse.ArgumentParser
133
104
  Parser to augment.
134
- context : FormatContext
105
+ context : DataConnectorContext
135
106
  Context for the format option: either ``'source'`` or ``'target'``
136
107
  """
137
108
  parser.set_defaults(_format_explicit=False)
138
- parser.add_argument(
139
- '--source-format',
140
- choices=list(FileFormat.choices()),
141
- default='json',
142
- action=_FormatAction,
143
- help=(
144
- f'Format of the {context}. Overrides filename-based inference '
145
- 'when provided.'
146
- ),
147
- )
148
- parser.add_argument(
149
- '--target-format',
150
- choices=list(FileFormat.choices()),
151
- default='json',
152
- action=_FormatAction,
153
- help=(
154
- f'Format of the {context}. Overrides filename-based inference '
155
- 'when provided.'
156
- ),
157
- )
158
-
159
-
160
- def _cli_description() -> str:
161
- return '\n'.join(
162
- [
163
- 'ETLPlus - A Swiss Army knife for simple ETL operations.',
164
- '',
165
- ' Provide a subcommand and options. Examples:',
166
- '',
167
- ' etlplus extract file in.csv > out.json',
168
- ' etlplus validate in.json --rules \'{"required": ["id"]}\'',
169
- (
170
- ' etlplus transform --from file in.csv --operations '
171
- '\'{"select": ["id"]}\' --to file -o out.json'
172
- ),
173
- ' etlplus extract in.csv | etlplus load --to file out.json',
174
- '',
175
- ' Override format inference when extensions are misleading:',
176
- '',
177
- ' etlplus extract data.txt --source-format csv',
178
- ' etlplus load payload.bin --target-format json',
179
- ],
180
- )
181
-
182
-
183
- def _cli_epilog() -> str:
184
- return '\n'.join(
185
- [
186
- 'Tip:',
187
- ' --source-format and --target-format override format '
188
- 'inference based on filename extensions when needed.',
189
- ],
190
- )
109
+ add_argparse_format_options(parser, context=context)
191
110
 
192
111
 
193
112
  def _emit_context_help(
@@ -292,8 +211,8 @@ def create_parser() -> argparse.ArgumentParser:
292
211
 
293
212
  parser = argparse.ArgumentParser(
294
213
  prog='etlplus',
295
- description=_cli_description(),
296
- epilog=_cli_epilog(),
214
+ description=CLI_DESCRIPTION,
215
+ epilog=CLI_EPILOG,
297
216
  formatter_class=argparse.RawDescriptionHelpFormatter,
298
217
  )
299
218
 
@@ -304,6 +223,25 @@ def create_parser() -> argparse.ArgumentParser:
304
223
  version=f'%(prog)s {__version__}',
305
224
  )
306
225
 
226
+ parser.add_argument(
227
+ '--pretty',
228
+ action=argparse.BooleanOptionalAction,
229
+ default=True,
230
+ help='Pretty-print JSON output (default: pretty).',
231
+ )
232
+ parser.add_argument(
233
+ '--quiet',
234
+ action=argparse.BooleanOptionalAction,
235
+ default=False,
236
+ help='Suppress warnings and non-essential output.',
237
+ )
238
+ parser.add_argument(
239
+ '--verbose',
240
+ action=argparse.BooleanOptionalAction,
241
+ default=False,
242
+ help='Emit extra diagnostics to stderr.',
243
+ )
244
+
307
245
  subparsers = parser.add_subparsers(
308
246
  dest='command',
309
247
  help='Available commands',
@@ -317,7 +255,7 @@ def create_parser() -> argparse.ArgumentParser:
317
255
  )
318
256
  extract_parser.add_argument(
319
257
  'source_type',
320
- choices=list(DataConnectorType.choices()),
258
+ choices=sorted(DATA_CONNECTORS),
321
259
  help='Type of source to extract from',
322
260
  )
323
261
  extract_parser.add_argument(
@@ -328,7 +266,7 @@ def create_parser() -> argparse.ArgumentParser:
328
266
  ),
329
267
  )
330
268
  _add_format_options(extract_parser, context='source')
331
- extract_parser.set_defaults(func=extract_handler)
269
+ extract_parser.set_defaults(func=handlers.extract_handler)
332
270
 
333
271
  validate_parser = subparsers.add_parser(
334
272
  'validate',
@@ -345,7 +283,7 @@ def create_parser() -> argparse.ArgumentParser:
345
283
  default={},
346
284
  help='Validation rules as JSON string',
347
285
  )
348
- validate_parser.set_defaults(func=validate_handler)
286
+ validate_parser.set_defaults(func=handlers.validate_handler)
349
287
 
350
288
  transform_parser = subparsers.add_parser(
351
289
  'transform',
@@ -365,18 +303,18 @@ def create_parser() -> argparse.ArgumentParser:
365
303
  transform_parser.add_argument(
366
304
  '--from',
367
305
  dest='from_',
368
- choices=list(DataConnectorType.choices()),
306
+ choices=sorted(DATA_CONNECTORS),
369
307
  help='Override the inferred source type (file, database, api).',
370
308
  )
371
309
  transform_parser.add_argument(
372
310
  '--to',
373
311
  dest='to',
374
- choices=list(DataConnectorType.choices()),
312
+ choices=sorted(DATA_CONNECTORS),
375
313
  help='Override the inferred target type (file, database, api).',
376
314
  )
377
315
  transform_parser.add_argument(
378
316
  '--source-format',
379
- choices=list(FileFormat.choices()),
317
+ choices=sorted(FILE_FORMATS),
380
318
  dest='source_format',
381
319
  help=(
382
320
  'Input payload format when SOURCE is - or a literal payload. '
@@ -386,14 +324,14 @@ def create_parser() -> argparse.ArgumentParser:
386
324
  transform_parser.add_argument(
387
325
  '--target-format',
388
326
  dest='target_format',
389
- choices=list(FileFormat.choices()),
327
+ choices=sorted(FILE_FORMATS),
390
328
  help=(
391
329
  'Output payload format '
392
330
  'when writing to stdout or non-file targets. '
393
331
  'File targets infer format from the extension.'
394
332
  ),
395
333
  )
396
- transform_parser.set_defaults(func=transform_handler)
334
+ transform_parser.set_defaults(func=handlers.transform_handler)
397
335
 
398
336
  load_parser = subparsers.add_parser(
399
337
  'load',
@@ -406,7 +344,7 @@ def create_parser() -> argparse.ArgumentParser:
406
344
  )
407
345
  load_parser.add_argument(
408
346
  'target_type',
409
- choices=list(DataConnectorType.choices()),
347
+ choices=sorted(DATA_CONNECTORS),
410
348
  help='Type of target to load to',
411
349
  )
412
350
  load_parser.add_argument(
@@ -417,7 +355,7 @@ def create_parser() -> argparse.ArgumentParser:
417
355
  ),
418
356
  )
419
357
  _add_format_options(load_parser, context='target')
420
- load_parser.set_defaults(func=load_handler)
358
+ load_parser.set_defaults(func=handlers.load_handler)
421
359
 
422
360
  render_parser = subparsers.add_parser(
423
361
  'render',
@@ -453,7 +391,7 @@ def create_parser() -> argparse.ArgumentParser:
453
391
  'Explicit path to a Jinja template file (overrides template key).'
454
392
  ),
455
393
  )
456
- render_parser.set_defaults(func=render_handler)
394
+ render_parser.set_defaults(func=handlers.render_handler)
457
395
 
458
396
  check_parser = subparsers.add_parser(
459
397
  'check',
@@ -493,7 +431,7 @@ def create_parser() -> argparse.ArgumentParser:
493
431
  name='transforms',
494
432
  help_text='List data transforms',
495
433
  )
496
- check_parser.set_defaults(func=check_handler)
434
+ check_parser.set_defaults(func=handlers.check_handler)
497
435
 
498
436
  run_parser = subparsers.add_parser(
499
437
  'run',
@@ -514,7 +452,7 @@ def create_parser() -> argparse.ArgumentParser:
514
452
  '--pipeline',
515
453
  help='Name of the pipeline to run',
516
454
  )
517
- run_parser.set_defaults(func=run_handler)
455
+ run_parser.set_defaults(func=handlers.run_handler)
518
456
 
519
457
  return parser
520
458