etlplus 0.5.2__py3-none-any.whl → 0.9.1__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,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