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/options.py ADDED
@@ -0,0 +1,115 @@
1
+ """
2
+ :mod:`etlplus.cli.options` module.
3
+
4
+ Shared command-line interface (CLI) option helpers for both Typer and argparse
5
+ entry points.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ from collections.abc import Sequence
12
+
13
+ from .constants import DEFAULT_FILE_FORMAT
14
+ from .constants import FILE_FORMATS
15
+ from .types import DataConnectorContext
16
+
17
+ # SECTION: EXPORTS ========================================================== #
18
+
19
+
20
+ __all__ = [
21
+ # Classes
22
+ 'FormatAction',
23
+ # Functions
24
+ 'add_argparse_format_options',
25
+ 'typer_format_option_kwargs',
26
+ ]
27
+
28
+
29
+ # SECTION: CLASSES ========================================================== #
30
+
31
+
32
+ class FormatAction(argparse.Action):
33
+ """Record when a format override flag is provided."""
34
+
35
+ def __call__(
36
+ self,
37
+ parser: argparse.ArgumentParser,
38
+ namespace: argparse.Namespace,
39
+ values: str | Sequence[object] | None,
40
+ option_string: str | None = None,
41
+ ) -> None: # pragma: no cover - argparse wiring
42
+ setattr(namespace, self.dest, values)
43
+ namespace._format_explicit = True
44
+
45
+
46
+ # SECTION: FUNCTIONS ======================================================== #
47
+
48
+
49
+ def add_argparse_format_options(
50
+ parser: argparse.ArgumentParser,
51
+ *,
52
+ context: DataConnectorContext,
53
+ ) -> None:
54
+ """
55
+ Attach ``--source-format`` and ``--target-format`` arguments.
56
+
57
+ Parameters
58
+ ----------
59
+ parser : argparse.ArgumentParser
60
+ Parser receiving the options.
61
+ context : DataConnectorContext
62
+ Either ``'source'`` or ``'target'`` to tailor help text.
63
+ """
64
+ parser.set_defaults(_format_explicit=False)
65
+ parser.add_argument(
66
+ '--source-format',
67
+ choices=sorted(FILE_FORMATS),
68
+ default=DEFAULT_FILE_FORMAT,
69
+ action=FormatAction,
70
+ help=(
71
+ f'Format of the {context}. Overrides filename-based inference '
72
+ 'when provided.'
73
+ ),
74
+ )
75
+ parser.add_argument(
76
+ '--target-format',
77
+ choices=sorted(FILE_FORMATS),
78
+ default=DEFAULT_FILE_FORMAT,
79
+ action=FormatAction,
80
+ help=(
81
+ f'Format of the {context}. Overrides filename-based inference '
82
+ 'when provided.'
83
+ ),
84
+ )
85
+
86
+
87
+ def typer_format_option_kwargs(
88
+ *,
89
+ context: DataConnectorContext,
90
+ rich_help_panel: str = 'Format overrides',
91
+ ) -> dict[str, object]:
92
+ """
93
+ Return common Typer option kwargs for format overrides.
94
+
95
+ Parameters
96
+ ----------
97
+ context : DataConnectorContext
98
+ Either ``'source'`` or ``'target'`` to tailor help text.
99
+ rich_help_panel : str, optional
100
+ The rich help panel name. Default is ``'Format overrides'``.
101
+
102
+ Returns
103
+ -------
104
+ dict[str, object]
105
+ The Typer option keyword arguments.
106
+ """
107
+ return {
108
+ 'metavar': 'FORMAT',
109
+ 'show_default': False,
110
+ 'rich_help_panel': rich_help_panel,
111
+ 'help': (
112
+ f'Payload format when the {context} is stdin/inline or a '
113
+ 'non-file connector. File connectors infer from extensions.'
114
+ ),
115
+ }
etlplus/cli/state.py ADDED
@@ -0,0 +1,411 @@
1
+ """
2
+ :mod:`etlplus.cli.state` module.
3
+
4
+ Shared state and helper utilities for the ``etlplus`` command-line interface
5
+ (CLI).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import sys
12
+ from collections.abc import Collection
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Final
16
+
17
+ import typer
18
+
19
+ from .constants import DATA_CONNECTORS
20
+
21
+ # SECTION: EXPORTS ========================================================== #
22
+
23
+ __all__ = [
24
+ # Classes
25
+ 'CliState',
26
+ # Functions
27
+ 'ensure_state',
28
+ 'format_namespace_kwargs',
29
+ 'infer_resource_type',
30
+ 'infer_resource_type_or_exit',
31
+ 'infer_resource_type_soft',
32
+ 'log_inferred_resource',
33
+ 'ns',
34
+ 'optional_choice',
35
+ 'resolve_resource_type',
36
+ 'stateful_namespace',
37
+ 'validate_choice',
38
+ ]
39
+
40
+
41
+ # SECTION: INTERNAL CONSTANTS =============================================== #
42
+
43
+
44
+ _DB_SCHEMES: Final[tuple[str, ...]] = (
45
+ 'postgres://',
46
+ 'postgresql://',
47
+ 'mysql://',
48
+ )
49
+
50
+
51
+ # SECTION: DATA CLASSES ===================================================== #
52
+
53
+
54
+ @dataclass(slots=True)
55
+ class CliState:
56
+ """
57
+ Mutable container for runtime CLI toggles.
58
+
59
+ Attributes
60
+ ----------
61
+ pretty : bool
62
+ Whether to pretty-print output.
63
+ quiet : bool
64
+ Whether to suppress non-error output.
65
+ verbose : bool
66
+ Whether to enable verbose logging.
67
+ """
68
+
69
+ pretty: bool = True
70
+ quiet: bool = False
71
+ verbose: bool = False
72
+
73
+
74
+ # SECTION: FUNCTIONS ======================================================== #
75
+
76
+
77
+ def ensure_state(
78
+ ctx: typer.Context,
79
+ ) -> CliState:
80
+ """
81
+ Return the :class:`CliState` stored on the :mod:`typer` context.
82
+
83
+ Initializes a new :class:`CliState` if none exists.
84
+
85
+ Parameters
86
+ ----------
87
+ ctx : typer.Context
88
+ The Typer command context.
89
+
90
+ Returns
91
+ -------
92
+ CliState
93
+ The CLI state object.
94
+ """
95
+ if not isinstance(getattr(ctx, 'obj', None), CliState):
96
+ ctx.obj = CliState()
97
+ return ctx.obj
98
+
99
+
100
+ def format_namespace_kwargs(
101
+ *,
102
+ format_value: str | None,
103
+ default: str,
104
+ ) -> dict[str, object]:
105
+ """
106
+ Return common namespace kwargs for format handling.
107
+
108
+ Parameters
109
+ ----------
110
+ format_value : str | None
111
+ The explicit format value from the CLI, or ``None`` if not provided.
112
+ default : str
113
+ The default format to use if none is provided.
114
+
115
+ Returns
116
+ -------
117
+ dict[str, object]
118
+ The namespace keyword arguments for format handling.
119
+ """
120
+ return {
121
+ 'format': (format_value or default),
122
+ '_format_explicit': (format_value is not None),
123
+ }
124
+
125
+
126
+ def infer_resource_type(
127
+ value: str,
128
+ ) -> str:
129
+ """
130
+ Infer the resource type from a path, URL, or DSN string.
131
+
132
+ Parameters
133
+ ----------
134
+ value : str
135
+ The resource identifier (path, URL, or DSN).
136
+
137
+ Returns
138
+ -------
139
+ str
140
+ The inferred resource type: ``file``, ``api``, or ``database``.
141
+
142
+ Raises
143
+ ------
144
+ ValueError
145
+ If inference fails.
146
+ """
147
+ val = (value or '').strip()
148
+ low = val.lower()
149
+
150
+ match (val, low):
151
+ case ('-', _):
152
+ return 'file'
153
+ case (_, inferred) if inferred.startswith(('http://', 'https://')):
154
+ return 'api'
155
+ case (_, inferred) if inferred.startswith(_DB_SCHEMES):
156
+ return 'database'
157
+
158
+ path = Path(val)
159
+ if path.exists() or path.suffix:
160
+ return 'file'
161
+
162
+ raise ValueError(
163
+ 'Could not infer resource type. Use --from/--to to specify it.',
164
+ )
165
+
166
+
167
+ def infer_resource_type_or_exit(
168
+ value: str,
169
+ ) -> str:
170
+ """
171
+ Infer a resource type and map ``ValueError`` to ``BadParameter``.
172
+
173
+ Parameters
174
+ ----------
175
+ value : str
176
+ The resource identifier (path, URL, or DSN).
177
+
178
+ Returns
179
+ -------
180
+ str
181
+ The inferred resource type: ``file``, ``api``, or ``database``.
182
+
183
+ Raises
184
+ ------
185
+ typer.BadParameter
186
+ If inference fails.
187
+ """
188
+ try:
189
+ return infer_resource_type(value)
190
+ except ValueError as exc: # pragma: no cover - exercised indirectly
191
+ raise typer.BadParameter(str(exc)) from exc
192
+
193
+
194
+ def infer_resource_type_soft(
195
+ value: str | None,
196
+ ) -> str | None:
197
+ """
198
+ Make a best-effort inference that tolerates inline payloads.
199
+
200
+ Parameters
201
+ ----------
202
+ value : str | None
203
+ The resource identifier (path, URL, DSN, or inline payload).
204
+
205
+ Returns
206
+ -------
207
+ str | None
208
+ The inferred resource type, or ``None`` if inference failed.
209
+ """
210
+ if value is None:
211
+ return None
212
+ try:
213
+ return infer_resource_type(value)
214
+ except ValueError:
215
+ return None
216
+
217
+
218
+ def log_inferred_resource(
219
+ state: CliState,
220
+ *,
221
+ role: str,
222
+ value: str,
223
+ resource_type: str | None,
224
+ ) -> None:
225
+ """
226
+ Emit a uniform verbose message for inferred resource types.
227
+
228
+ Parameters
229
+ ----------
230
+ state : CliState
231
+ The current CLI state.
232
+ role : str
233
+ The resource role, e.g., ``source`` or ``target``.
234
+ value : str
235
+ The resource identifier (path, URL, or DSN).
236
+ resource_type : str | None
237
+ The inferred resource type, or ``None`` if inference failed.
238
+ """
239
+ if not state.verbose or resource_type is None:
240
+ return
241
+ print(
242
+ f'Inferred {role}_type={resource_type} for {role}={value}',
243
+ file=sys.stderr,
244
+ )
245
+
246
+
247
+ def ns(
248
+ **kwargs: object,
249
+ ) -> argparse.Namespace:
250
+ """
251
+ Build an :class:`argparse.Namespace` for the legacy handlers.
252
+
253
+ Parameters
254
+ ----------
255
+ **kwargs : object
256
+ Keyword arguments to include in the namespace.
257
+
258
+ Returns
259
+ -------
260
+ argparse.Namespace
261
+ The constructed namespace.
262
+ """
263
+ return argparse.Namespace(**kwargs)
264
+
265
+
266
+ def optional_choice(
267
+ value: str | None,
268
+ choices: Collection[str],
269
+ *,
270
+ label: str,
271
+ ) -> str | None:
272
+ """
273
+ Validate optional CLI choice inputs while preserving ``None``.
274
+
275
+ Parameters
276
+ ----------
277
+ value : str | None
278
+ The input value to validate, or ``None``.
279
+ choices : Collection[str]
280
+ The set of valid choices.
281
+ label : str
282
+ The label for error messages.
283
+
284
+ Returns
285
+ -------
286
+ str | None
287
+ The validated choice, or ``None`` if input was ``None``.
288
+ """
289
+ if value is None:
290
+ return None
291
+ return validate_choice(value, choices, label=label)
292
+
293
+
294
+ def resolve_resource_type(
295
+ *,
296
+ explicit_type: str | None,
297
+ override_type: str | None,
298
+ value: str,
299
+ label: str,
300
+ conflict_error: str | None = None,
301
+ legacy_file_error: str | None = None,
302
+ ) -> str:
303
+ """
304
+ Resolve resource type preference order and validate it.
305
+
306
+ Parameters
307
+ ----------
308
+ explicit_type : str | None
309
+ The explicit resource type from the CLI, or ``None`` if not provided.
310
+ override_type : str | None
311
+ The override resource type from the CLI, or ``None`` if not provided.
312
+ value : str
313
+ The resource identifier (path, URL, or DSN).
314
+ label : str
315
+ The label for error messages.
316
+ conflict_error : str | None, optional
317
+ The error message to raise if both explicit and override types are
318
+ provided, by default ``None``.
319
+ legacy_file_error : str | None, optional
320
+ The error message to raise if the explicit type is ``file``, by default
321
+ ``None``.
322
+
323
+ Returns
324
+ -------
325
+ str
326
+ The resolved and validated resource type.
327
+
328
+ Raises
329
+ ------
330
+ typer.BadParameter
331
+ If there is a conflict between explicit and override types, or if the
332
+ explicit type is ``file`` when disallowed.
333
+ """
334
+ if explicit_type is not None:
335
+ if override_type is not None and conflict_error:
336
+ raise typer.BadParameter(conflict_error)
337
+ if legacy_file_error and explicit_type.strip().lower() == 'file':
338
+ raise typer.BadParameter(legacy_file_error)
339
+ candidate = explicit_type
340
+ else:
341
+ candidate = override_type or infer_resource_type_or_exit(value)
342
+ return validate_choice(candidate, DATA_CONNECTORS, label=label)
343
+
344
+
345
+ def stateful_namespace(
346
+ state: CliState,
347
+ *,
348
+ command: str,
349
+ **kwargs: object,
350
+ ) -> argparse.Namespace:
351
+ """
352
+ Attach CLI state toggles to a handler namespace.
353
+
354
+ Parameters
355
+ ----------
356
+ state : CliState
357
+ The current CLI state.
358
+ command : str
359
+ The command name.
360
+ **kwargs : object
361
+ Additional keyword arguments for the namespace.
362
+
363
+ Returns
364
+ -------
365
+ argparse.Namespace
366
+ The constructed namespace with state toggles.
367
+ """
368
+ return ns(
369
+ command=command,
370
+ pretty=state.pretty,
371
+ quiet=state.quiet,
372
+ verbose=state.verbose,
373
+ **kwargs,
374
+ )
375
+
376
+
377
+ def validate_choice(
378
+ value: str,
379
+ choices: Collection[str],
380
+ *,
381
+ label: str,
382
+ ) -> str:
383
+ """
384
+ Validate CLI input against a whitelist of choices.
385
+
386
+ Parameters
387
+ ----------
388
+ value : str
389
+ The input value to validate.
390
+ choices : Collection[str]
391
+ The set of valid choices.
392
+ label : str
393
+ The label for error messages.
394
+
395
+ Returns
396
+ -------
397
+ str
398
+ The validated choice.
399
+
400
+ Raises
401
+ ------
402
+ typer.BadParameter
403
+ If the input value is not in the set of valid choices.
404
+ """
405
+ v = (value or '').strip()
406
+ if v in choices:
407
+ return v
408
+ allowed = ', '.join(sorted(choices))
409
+ raise typer.BadParameter(
410
+ f"Invalid {label} '{value}'. Choose from: {allowed}",
411
+ )
etlplus/cli/types.py ADDED
@@ -0,0 +1,33 @@
1
+ """
2
+ :mod:`etlplus.cli.types` module.
3
+
4
+ Type aliases for :mod:`etlplus.cli` helpers.
5
+
6
+ Notes
7
+ -----
8
+ - Keeps other modules decoupled from ``typing`` details.
9
+
10
+ Examples
11
+ --------
12
+ >>> from etlplus.cli.types import DataConnectorContext
13
+ >>> connector: DataConnectorContext = 'source'
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Literal
19
+
20
+ # SECTION: EXPORTS ========================================================== #
21
+
22
+
23
+ __all__ = [
24
+ # Type Aliases
25
+ 'DataConnectorContext',
26
+ ]
27
+
28
+
29
+ # SECTION: TYPE ALIASES ===================================================== #
30
+
31
+
32
+ # Data connector context.
33
+ type DataConnectorContext = Literal['source', 'target']
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: etlplus
3
- Version: 0.8.0
3
+ Version: 0.8.2
4
4
  Summary: A Swiss Army knife for simple ETL operations
5
5
  Home-page: https://github.com/Dagitali/ETLPlus
6
6
  Author: ETLPlus Team
@@ -31,9 +31,14 @@ etlplus/api/rate_limiting/__init__.py,sha256=ZySB1dZettEDnWvI1EHf_TZ9L08M_kKsNR-
31
31
  etlplus/api/rate_limiting/config.py,sha256=2b4wIynblN-1EyMqI4aXa71SljzSjXYh5N1Nngr3jOg,9406
32
32
  etlplus/api/rate_limiting/rate_limiter.py,sha256=Uxozqd_Ej5Lsj-M-mLT2WexChgWh7x35_YP10yqYPQA,7159
33
33
  etlplus/cli/__init__.py,sha256=J97-Rv931IL1_b4AXnB7Fbbd7HKnHBpx18NQfC_kE6c,299
34
- etlplus/cli/app.py,sha256=kQuMmY-RqadRGWS5yDpkKl5bWdOpqbR0jqsXCORLsMA,34532
35
- etlplus/cli/handlers.py,sha256=5Blk5LSqTVSwTPPGEcbOw2NwNTTbFFOTxlpUdy5r2bA,17928
36
- etlplus/cli/main.py,sha256=1U2WS-yIxTEr6ZJJkQFtisCxOcZ3cdnjIxUTewapN5M,15878
34
+ etlplus/cli/commands.py,sha256=laIfpxnnJM1XZvxQxaH1IheZbIsELruC1V3BeT2l8y0,16299
35
+ etlplus/cli/constants.py,sha256=NJ6IvNyYEI8IdB7eMcc-vteQiiIwqid5YvmUk-5DRHY,1839
36
+ etlplus/cli/handlers.py,sha256=F8ZtXWdtMvhDYYDUjZ3JkGFJh2xfWsE2PKX5ZRhP0RI,11310
37
+ etlplus/cli/io.py,sha256=wHoKfgl-VT15cfiVeW3uPz_U7tPQ1ag7EQkRZtxPd38,7987
38
+ etlplus/cli/main.py,sha256=iD4zSgJwsfzhqlBCNxD7a7y5ZutqB9upxCnGtNaZ_B8,14006
39
+ etlplus/cli/options.py,sha256=44rgksvi0c9Evt_QzITnErVEuyGTaUulkQYZZgCke7k,3060
40
+ etlplus/cli/state.py,sha256=ymiGJbnEO766BOYaqm_-qZaqBwdKYnGRuttrNVhDms0,9439
41
+ etlplus/cli/types.py,sha256=tclhKVJXDqHzlTQBYKARfqMgDOcuBJ-Zej2pvFy96WM,652
37
42
  etlplus/config/__init__.py,sha256=VZWzOg7d2YR9NT6UwKTv44yf2FRUMjTHynkm1Dl5Qzo,1486
38
43
  etlplus/config/connector.py,sha256=0-TIwevHbKRHVmucvyGpPd-3tB1dKHB-dj0yJ6kq5eY,9809
39
44
  etlplus/config/jobs.py,sha256=hmzRCqt0OvCEZZR4ONKrd3lvSv0OmayjLc4yOBk3ug8,7399
@@ -52,9 +57,9 @@ etlplus/templates/ddl.sql.j2,sha256=s8fMWvcb4eaJVXkifuib1aQPljtZ8buuyB_uA-ZdU3Q,
52
57
  etlplus/templates/view.sql.j2,sha256=Iy8DHfhq5yyvrUKDxqp_aHIEXY4Tm6j4wT7YDEFWAhk,2180
53
58
  etlplus/validation/__init__.py,sha256=Pe5Xg1_EA4uiNZGYu5WTF3j7odjmyxnAJ8rcioaplSQ,1254
54
59
  etlplus/validation/utils.py,sha256=Mtqg449VIke0ziy_wd2r6yrwJzQkA1iulZC87FzXMjo,10201
55
- etlplus-0.8.0.dist-info/licenses/LICENSE,sha256=MuNO63i6kWmgnV2pbP2SLqP54mk1BGmu7CmbtxMmT-U,1069
56
- etlplus-0.8.0.dist-info/METADATA,sha256=VjENPm-4JBGKuJ_NIjRLjce135eB7_ZTOFY6ZmAdS30,19328
57
- etlplus-0.8.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
58
- etlplus-0.8.0.dist-info/entry_points.txt,sha256=6w-2-jzuPa55spzK34h-UKh2JTEShh38adFRONNP9QE,45
59
- etlplus-0.8.0.dist-info/top_level.txt,sha256=aWWF-udn_sLGuHTM6W6MLh99ArS9ROkUWO8Mi8y1_2U,8
60
- etlplus-0.8.0.dist-info/RECORD,,
60
+ etlplus-0.8.2.dist-info/licenses/LICENSE,sha256=MuNO63i6kWmgnV2pbP2SLqP54mk1BGmu7CmbtxMmT-U,1069
61
+ etlplus-0.8.2.dist-info/METADATA,sha256=djlodCP_KPtdG2OKttP5QcpxJHCy3-0enqnIXKIZZEY,19328
62
+ etlplus-0.8.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
63
+ etlplus-0.8.2.dist-info/entry_points.txt,sha256=6w-2-jzuPa55spzK34h-UKh2JTEShh38adFRONNP9QE,45
64
+ etlplus-0.8.2.dist-info/top_level.txt,sha256=aWWF-udn_sLGuHTM6W6MLh99ArS9ROkUWO8Mi8y1_2U,8
65
+ etlplus-0.8.2.dist-info/RECORD,,