etlplus 0.4.7__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.
etlplus/cli/io.py ADDED
@@ -0,0 +1,320 @@
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
+ if isinstance(source, (dict, list)):
143
+ return cast(JSONData, source)
144
+ if not isinstance(source, (str, os.PathLike)):
145
+ return source
146
+
147
+ path = Path(source)
148
+
149
+ normalized_hint = (format_hint or '').strip().lower()
150
+ fmt: FileFormat | None = None
151
+
152
+ if format_explicit and normalized_hint:
153
+ try:
154
+ fmt = FileFormat(normalized_hint)
155
+ except ValueError:
156
+ fmt = None
157
+ elif not format_explicit:
158
+ suffix = path.suffix.lower().lstrip('.')
159
+ if suffix:
160
+ try:
161
+ fmt = FileFormat(suffix)
162
+ except ValueError:
163
+ fmt = None
164
+
165
+ if fmt is None:
166
+ return source
167
+ if fmt == FileFormat.CSV:
168
+ return read_csv_rows(path)
169
+ return File(path, fmt).read()
170
+
171
+
172
+ def parse_json_payload(text: str) -> JSONData:
173
+ """
174
+ Parse JSON text and surface a concise error when it fails.
175
+
176
+ Parameters
177
+ ----------
178
+ text : str
179
+ The JSON text to parse.
180
+
181
+ Returns
182
+ -------
183
+ JSONData
184
+ The parsed JSON data.
185
+
186
+ Raises
187
+ ------
188
+ ValueError
189
+ When the JSON text is invalid.
190
+ """
191
+ try:
192
+ return cast(JSONData, json.loads(text))
193
+ except json.JSONDecodeError as e:
194
+ raise ValueError(
195
+ f'Invalid JSON payload: {e.msg} (pos {e.pos})',
196
+ ) from e
197
+
198
+
199
+ def parse_text_payload(
200
+ text: str,
201
+ fmt: str | None,
202
+ ) -> JSONData | str:
203
+ """
204
+ Parse JSON/CSV text into a Python payload.
205
+
206
+ Parameters
207
+ ----------
208
+ text : str
209
+ The text payload to parse.
210
+ fmt : str | None
211
+ An optional format hint (e.g., 'json', 'csv').
212
+
213
+ Returns
214
+ -------
215
+ JSONData | str
216
+ The parsed payload as JSON data or raw text.
217
+ """
218
+ effective = (fmt or '').strip().lower() or infer_payload_format(text)
219
+ if effective == 'json':
220
+ return parse_json_payload(text)
221
+ if effective == 'csv':
222
+ reader = csv.DictReader(_io.StringIO(text))
223
+ return [dict(row) for row in reader]
224
+ return text
225
+
226
+
227
+ def read_csv_rows(
228
+ path: Path,
229
+ ) -> list[dict[str, str]]:
230
+ """
231
+ Read CSV rows into dictionaries.
232
+
233
+ Parameters
234
+ ----------
235
+ path : Path
236
+ The path to the CSV file.
237
+
238
+ Returns
239
+ -------
240
+ list[dict[str, str]]
241
+ The list of CSV rows as dictionaries.
242
+ """
243
+ with path.open(newline='', encoding='utf-8') as handle:
244
+ reader = csv.DictReader(handle)
245
+ return [dict(row) for row in reader]
246
+
247
+
248
+ def read_stdin_text() -> str:
249
+ """Return entire stdin payload."""
250
+ return sys.stdin.read()
251
+
252
+
253
+ def resolve_cli_payload(
254
+ source: object,
255
+ *,
256
+ format_hint: str | None,
257
+ format_explicit: bool,
258
+ hydrate_files: bool = True,
259
+ ) -> JSONData | object:
260
+ """
261
+ Normalize CLI-provided payloads, honoring stdin and inline data.
262
+
263
+ Parameters
264
+ ----------
265
+ source : object
266
+ The source payload, potentially stdin or a file path.
267
+ format_hint : str | None
268
+ An optional format hint (e.g., 'json', 'csv').
269
+ format_explicit : bool
270
+ Whether the format hint was explicitly provided.
271
+ hydrate_files : bool, optional
272
+ Whether to materialize file-based payloads. Default is True.
273
+
274
+ Returns
275
+ -------
276
+ JSONData | object
277
+ The resolved payload.
278
+ """
279
+ if isinstance(source, (os.PathLike, str)) and str(source) == '-':
280
+ text = read_stdin_text()
281
+ return parse_text_payload(text, format_hint)
282
+
283
+ if not hydrate_files:
284
+ return source
285
+
286
+ return materialize_file_payload(
287
+ source,
288
+ format_hint=format_hint,
289
+ format_explicit=format_explicit,
290
+ )
291
+
292
+
293
+ def write_json_output(
294
+ data: Any,
295
+ output_path: str | None,
296
+ *,
297
+ success_message: str,
298
+ ) -> bool:
299
+ """
300
+ Persist JSON data to disk when output path provided.
301
+
302
+ Parameters
303
+ ----------
304
+ data : Any
305
+ The data to serialize as JSON.
306
+ output_path : str | None
307
+ The output file path, or None/'-' to skip writing.
308
+ success_message : str
309
+ The message to print upon successful write.
310
+
311
+ Returns
312
+ -------
313
+ bool
314
+ True if data was written to disk; False if not.
315
+ """
316
+ if not output_path or output_path == '-':
317
+ return False
318
+ File(Path(output_path), FileFormat.JSON).write_json(data)
319
+ print(f'{success_message} {output_path}')
320
+ return True