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.
- etlplus/api/README.md +24 -26
- etlplus/cli/commands.py +924 -0
- etlplus/cli/constants.py +71 -0
- etlplus/cli/handlers.py +302 -420
- etlplus/cli/io.py +336 -0
- etlplus/cli/main.py +16 -418
- etlplus/cli/options.py +49 -0
- etlplus/cli/state.py +336 -0
- etlplus/cli/types.py +33 -0
- etlplus/database/__init__.py +2 -0
- etlplus/database/ddl.py +37 -29
- etlplus/database/engine.py +10 -5
- etlplus/database/orm.py +18 -11
- etlplus/database/schema.py +3 -2
- etlplus/database/types.py +33 -0
- etlplus/load.py +1 -1
- etlplus/types.py +5 -0
- etlplus/utils.py +1 -32
- {etlplus-0.7.0.dist-info → etlplus-0.9.0.dist-info}/METADATA +65 -32
- {etlplus-0.7.0.dist-info → etlplus-0.9.0.dist-info}/RECORD +24 -18
- etlplus/cli/app.py +0 -1367
- {etlplus-0.7.0.dist-info → etlplus-0.9.0.dist-info}/WHEEL +0 -0
- {etlplus-0.7.0.dist-info → etlplus-0.9.0.dist-info}/entry_points.txt +0 -0
- {etlplus-0.7.0.dist-info → etlplus-0.9.0.dist-info}/licenses/LICENSE +0 -0
- {etlplus-0.7.0.dist-info → etlplus-0.9.0.dist-info}/top_level.txt +0 -0
etlplus/cli/io.py
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
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 csv
|
|
10
|
+
import io as _io
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
from typing import cast
|
|
17
|
+
|
|
18
|
+
from ..enums import FileFormat
|
|
19
|
+
from ..file import File
|
|
20
|
+
from ..types import JSONData
|
|
21
|
+
from ..utils import print_json
|
|
22
|
+
|
|
23
|
+
# SECTION: EXPORTS ========================================================== #
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
# Functions
|
|
28
|
+
'emit_json',
|
|
29
|
+
'emit_or_write',
|
|
30
|
+
'infer_payload_format',
|
|
31
|
+
'materialize_file_payload',
|
|
32
|
+
'parse_json_payload',
|
|
33
|
+
'parse_text_payload',
|
|
34
|
+
'read_csv_rows',
|
|
35
|
+
'read_stdin_text',
|
|
36
|
+
'resolve_cli_payload',
|
|
37
|
+
'write_json_output',
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# SECTION: FUNCTIONS ======================================================== #
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def emit_json(
|
|
45
|
+
data: Any,
|
|
46
|
+
*,
|
|
47
|
+
pretty: bool,
|
|
48
|
+
) -> None:
|
|
49
|
+
"""
|
|
50
|
+
Emit JSON honoring pretty/compact preference.
|
|
51
|
+
|
|
52
|
+
Parameters
|
|
53
|
+
----------
|
|
54
|
+
data : Any
|
|
55
|
+
Data to serialize as JSON.
|
|
56
|
+
pretty : bool
|
|
57
|
+
Whether to pretty-print JSON output.
|
|
58
|
+
"""
|
|
59
|
+
if pretty:
|
|
60
|
+
print_json(data)
|
|
61
|
+
return
|
|
62
|
+
dumped = json.dumps(data, ensure_ascii=False, separators=(',', ':'))
|
|
63
|
+
print(dumped)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def emit_or_write(
|
|
67
|
+
data: Any,
|
|
68
|
+
output_path: str | None,
|
|
69
|
+
*,
|
|
70
|
+
pretty: bool,
|
|
71
|
+
success_message: str,
|
|
72
|
+
) -> None:
|
|
73
|
+
"""
|
|
74
|
+
Emit JSON or persist to disk based on ``output_path``.
|
|
75
|
+
|
|
76
|
+
Parameters
|
|
77
|
+
----------
|
|
78
|
+
data : Any
|
|
79
|
+
The data to serialize.
|
|
80
|
+
output_path : str | None
|
|
81
|
+
Target file path; when falsy or ``'-'`` data is emitted to STDOUT.
|
|
82
|
+
pretty : bool
|
|
83
|
+
Whether to pretty-print JSON emission.
|
|
84
|
+
success_message : str
|
|
85
|
+
Message printed when writing to disk succeeds.
|
|
86
|
+
"""
|
|
87
|
+
if write_json_output(
|
|
88
|
+
data,
|
|
89
|
+
output_path,
|
|
90
|
+
success_message=success_message,
|
|
91
|
+
):
|
|
92
|
+
return
|
|
93
|
+
emit_json(data, pretty=pretty)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def infer_payload_format(
|
|
97
|
+
text: str,
|
|
98
|
+
) -> str:
|
|
99
|
+
"""
|
|
100
|
+
Infer JSON vs CSV from payload text.
|
|
101
|
+
|
|
102
|
+
Parameters
|
|
103
|
+
----------
|
|
104
|
+
text : str
|
|
105
|
+
The payload text to analyze.
|
|
106
|
+
|
|
107
|
+
Returns
|
|
108
|
+
-------
|
|
109
|
+
str
|
|
110
|
+
The inferred format: either 'json' or 'csv'.
|
|
111
|
+
"""
|
|
112
|
+
stripped = text.lstrip()
|
|
113
|
+
if stripped.startswith('{') or stripped.startswith('['):
|
|
114
|
+
return 'json'
|
|
115
|
+
return 'csv'
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def materialize_file_payload(
|
|
119
|
+
source: object,
|
|
120
|
+
*,
|
|
121
|
+
format_hint: str | None,
|
|
122
|
+
format_explicit: bool,
|
|
123
|
+
) -> JSONData | object:
|
|
124
|
+
"""
|
|
125
|
+
Return structured payloads when ``source`` references a file.
|
|
126
|
+
|
|
127
|
+
Parameters
|
|
128
|
+
----------
|
|
129
|
+
source : object
|
|
130
|
+
The source payload, potentially a file path.
|
|
131
|
+
format_hint : str | None
|
|
132
|
+
An optional format hint (e.g., 'json', 'csv').
|
|
133
|
+
format_explicit : bool
|
|
134
|
+
Whether the format hint was explicitly provided.
|
|
135
|
+
|
|
136
|
+
Returns
|
|
137
|
+
-------
|
|
138
|
+
JSONData | object
|
|
139
|
+
The materialized payload if a file was read, otherwise the original
|
|
140
|
+
source.
|
|
141
|
+
|
|
142
|
+
Raises
|
|
143
|
+
------
|
|
144
|
+
FileNotFoundError
|
|
145
|
+
When the specified file does not exist.
|
|
146
|
+
"""
|
|
147
|
+
if isinstance(source, (dict, list)):
|
|
148
|
+
return cast(JSONData, source)
|
|
149
|
+
if not isinstance(source, (str, os.PathLike)):
|
|
150
|
+
return source
|
|
151
|
+
|
|
152
|
+
path = Path(source)
|
|
153
|
+
|
|
154
|
+
normalized_hint = (format_hint or '').strip().lower()
|
|
155
|
+
fmt: FileFormat | None = None
|
|
156
|
+
|
|
157
|
+
if format_explicit and normalized_hint:
|
|
158
|
+
try:
|
|
159
|
+
fmt = FileFormat(normalized_hint)
|
|
160
|
+
except ValueError:
|
|
161
|
+
fmt = None
|
|
162
|
+
elif not format_explicit:
|
|
163
|
+
suffix = path.suffix.lower().lstrip('.')
|
|
164
|
+
if suffix:
|
|
165
|
+
try:
|
|
166
|
+
fmt = FileFormat(suffix)
|
|
167
|
+
except ValueError:
|
|
168
|
+
fmt = None
|
|
169
|
+
|
|
170
|
+
if fmt is None:
|
|
171
|
+
return source
|
|
172
|
+
if not path.exists():
|
|
173
|
+
if isinstance(source, str):
|
|
174
|
+
stripped = source.lstrip()
|
|
175
|
+
hint = (format_hint or '').strip().lower()
|
|
176
|
+
if (
|
|
177
|
+
stripped.startswith(('{', '['))
|
|
178
|
+
or '\n' in source
|
|
179
|
+
or (hint == 'csv' and ',' in source)
|
|
180
|
+
):
|
|
181
|
+
return parse_text_payload(source, format_hint)
|
|
182
|
+
raise FileNotFoundError(f'File not found: {path}')
|
|
183
|
+
if fmt == FileFormat.CSV:
|
|
184
|
+
return read_csv_rows(path)
|
|
185
|
+
return File(path, fmt).read()
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def parse_json_payload(text: str) -> JSONData:
|
|
189
|
+
"""
|
|
190
|
+
Parse JSON text and surface a concise error when it fails.
|
|
191
|
+
|
|
192
|
+
Parameters
|
|
193
|
+
----------
|
|
194
|
+
text : str
|
|
195
|
+
The JSON text to parse.
|
|
196
|
+
|
|
197
|
+
Returns
|
|
198
|
+
-------
|
|
199
|
+
JSONData
|
|
200
|
+
The parsed JSON data.
|
|
201
|
+
|
|
202
|
+
Raises
|
|
203
|
+
------
|
|
204
|
+
ValueError
|
|
205
|
+
When the JSON text is invalid.
|
|
206
|
+
"""
|
|
207
|
+
try:
|
|
208
|
+
return cast(JSONData, json.loads(text))
|
|
209
|
+
except json.JSONDecodeError as e:
|
|
210
|
+
raise ValueError(
|
|
211
|
+
f'Invalid JSON payload: {e.msg} (pos {e.pos})',
|
|
212
|
+
) from e
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def parse_text_payload(
|
|
216
|
+
text: str,
|
|
217
|
+
fmt: str | None,
|
|
218
|
+
) -> JSONData | str:
|
|
219
|
+
"""
|
|
220
|
+
Parse JSON/CSV text into a Python payload.
|
|
221
|
+
|
|
222
|
+
Parameters
|
|
223
|
+
----------
|
|
224
|
+
text : str
|
|
225
|
+
The text payload to parse.
|
|
226
|
+
fmt : str | None
|
|
227
|
+
An optional format hint (e.g., 'json', 'csv').
|
|
228
|
+
|
|
229
|
+
Returns
|
|
230
|
+
-------
|
|
231
|
+
JSONData | str
|
|
232
|
+
The parsed payload as JSON data or raw text.
|
|
233
|
+
"""
|
|
234
|
+
effective = (fmt or '').strip().lower() or infer_payload_format(text)
|
|
235
|
+
if effective == 'json':
|
|
236
|
+
return parse_json_payload(text)
|
|
237
|
+
if effective == 'csv':
|
|
238
|
+
reader = csv.DictReader(_io.StringIO(text))
|
|
239
|
+
return [dict(row) for row in reader]
|
|
240
|
+
return text
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def read_csv_rows(
|
|
244
|
+
path: Path,
|
|
245
|
+
) -> list[dict[str, str]]:
|
|
246
|
+
"""
|
|
247
|
+
Read CSV rows into dictionaries.
|
|
248
|
+
|
|
249
|
+
Parameters
|
|
250
|
+
----------
|
|
251
|
+
path : Path
|
|
252
|
+
The path to the CSV file.
|
|
253
|
+
|
|
254
|
+
Returns
|
|
255
|
+
-------
|
|
256
|
+
list[dict[str, str]]
|
|
257
|
+
The list of CSV rows as dictionaries.
|
|
258
|
+
"""
|
|
259
|
+
with path.open(newline='', encoding='utf-8') as handle:
|
|
260
|
+
reader = csv.DictReader(handle)
|
|
261
|
+
return [dict(row) for row in reader]
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def read_stdin_text() -> str:
|
|
265
|
+
"""Return entire STDIN payload."""
|
|
266
|
+
return sys.stdin.read()
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def resolve_cli_payload(
|
|
270
|
+
source: object,
|
|
271
|
+
*,
|
|
272
|
+
format_hint: str | None,
|
|
273
|
+
format_explicit: bool,
|
|
274
|
+
hydrate_files: bool = True,
|
|
275
|
+
) -> JSONData | object:
|
|
276
|
+
"""
|
|
277
|
+
Normalize CLI-provided payloads, honoring STDIN and inline data.
|
|
278
|
+
|
|
279
|
+
Parameters
|
|
280
|
+
----------
|
|
281
|
+
source : object
|
|
282
|
+
The source payload, potentially STDIN or a file path.
|
|
283
|
+
format_hint : str | None
|
|
284
|
+
An optional format hint (e.g., 'json', 'csv').
|
|
285
|
+
format_explicit : bool
|
|
286
|
+
Whether the format hint was explicitly provided.
|
|
287
|
+
hydrate_files : bool, optional
|
|
288
|
+
Whether to materialize file-based payloads. Default is True.
|
|
289
|
+
|
|
290
|
+
Returns
|
|
291
|
+
-------
|
|
292
|
+
JSONData | object
|
|
293
|
+
The resolved payload.
|
|
294
|
+
"""
|
|
295
|
+
if isinstance(source, (os.PathLike, str)) and str(source) == '-':
|
|
296
|
+
text = read_stdin_text()
|
|
297
|
+
return parse_text_payload(text, format_hint)
|
|
298
|
+
|
|
299
|
+
if not hydrate_files:
|
|
300
|
+
return source
|
|
301
|
+
|
|
302
|
+
return materialize_file_payload(
|
|
303
|
+
source,
|
|
304
|
+
format_hint=format_hint,
|
|
305
|
+
format_explicit=format_explicit,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def write_json_output(
|
|
310
|
+
data: Any,
|
|
311
|
+
output_path: str | None,
|
|
312
|
+
*,
|
|
313
|
+
success_message: str,
|
|
314
|
+
) -> bool:
|
|
315
|
+
"""
|
|
316
|
+
Persist JSON data to disk when output path provided.
|
|
317
|
+
|
|
318
|
+
Parameters
|
|
319
|
+
----------
|
|
320
|
+
data : Any
|
|
321
|
+
The data to serialize as JSON.
|
|
322
|
+
output_path : str | None
|
|
323
|
+
The output file path, or None/'-' to skip writing.
|
|
324
|
+
success_message : str
|
|
325
|
+
The message to print upon successful write.
|
|
326
|
+
|
|
327
|
+
Returns
|
|
328
|
+
-------
|
|
329
|
+
bool
|
|
330
|
+
True if data was written to disk; False if not.
|
|
331
|
+
"""
|
|
332
|
+
if not output_path or output_path == '-':
|
|
333
|
+
return False
|
|
334
|
+
File(Path(output_path), FileFormat.JSON).write_json(data)
|
|
335
|
+
print(f'{success_message} {output_path}')
|
|
336
|
+
return True
|