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/state.py ADDED
@@ -0,0 +1,335 @@
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 sys
11
+ from collections.abc import Collection
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+ from typing import Final
15
+
16
+ import typer
17
+
18
+ from .constants import DATA_CONNECTORS
19
+
20
+ # SECTION: EXPORTS ========================================================== #
21
+
22
+ __all__ = [
23
+ # Classes
24
+ 'CliState',
25
+ # Functions
26
+ 'ensure_state',
27
+ 'infer_resource_type',
28
+ 'infer_resource_type_or_exit',
29
+ 'infer_resource_type_soft',
30
+ 'log_inferred_resource',
31
+ 'optional_choice',
32
+ 'resolve_resource_type',
33
+ 'validate_choice',
34
+ ]
35
+
36
+
37
+ # SECTION: INTERNAL CONSTANTS =============================================== #
38
+
39
+
40
+ _DB_SCHEMES: Final[tuple[str, ...]] = (
41
+ 'postgres://',
42
+ 'postgresql://',
43
+ 'mysql://',
44
+ )
45
+
46
+
47
+ # SECTION: DATA CLASSES ===================================================== #
48
+
49
+
50
+ @dataclass(slots=True)
51
+ class CliState:
52
+ """
53
+ Mutable container for runtime CLI toggles.
54
+
55
+ Attributes
56
+ ----------
57
+ pretty : bool
58
+ Whether to pretty-print output.
59
+ quiet : bool
60
+ Whether to suppress non-error output.
61
+ verbose : bool
62
+ Whether to enable verbose logging.
63
+ """
64
+
65
+ pretty: bool = True
66
+ quiet: bool = False
67
+ verbose: bool = False
68
+
69
+
70
+ # SECTION: FUNCTIONS ======================================================== #
71
+
72
+
73
+ def ensure_state(
74
+ ctx: typer.Context,
75
+ ) -> CliState:
76
+ """
77
+ Return the :class:`CliState` stored on the :mod:`typer` context.
78
+
79
+ Initializes a new :class:`CliState` if none exists.
80
+
81
+ Parameters
82
+ ----------
83
+ ctx : typer.Context
84
+ The Typer command context.
85
+
86
+ Returns
87
+ -------
88
+ CliState
89
+ The CLI state object.
90
+ """
91
+ if not isinstance(getattr(ctx, 'obj', None), CliState):
92
+ ctx.obj = CliState()
93
+ return ctx.obj
94
+
95
+
96
+ def infer_resource_type(
97
+ value: str,
98
+ ) -> str:
99
+ """
100
+ Infer the resource type from a path, URL, or DSN string.
101
+
102
+ Parameters
103
+ ----------
104
+ value : str
105
+ The resource identifier (path, URL, or DSN).
106
+
107
+ Returns
108
+ -------
109
+ str
110
+ The inferred resource type: ``file``, ``api``, or ``database``.
111
+
112
+ Raises
113
+ ------
114
+ ValueError
115
+ If inference fails.
116
+ """
117
+ val = (value or '').strip()
118
+ low = val.lower()
119
+
120
+ match (val, low):
121
+ case ('-', _):
122
+ return 'file'
123
+ case (_, inferred) if inferred.startswith(('http://', 'https://')):
124
+ return 'api'
125
+ case (_, inferred) if inferred.startswith(_DB_SCHEMES):
126
+ return 'database'
127
+
128
+ path = Path(val)
129
+ if path.exists() or path.suffix:
130
+ return 'file'
131
+
132
+ raise ValueError(
133
+ 'Could not infer resource type. Use --from/--to to specify it.',
134
+ )
135
+
136
+
137
+ def infer_resource_type_or_exit(
138
+ value: str,
139
+ ) -> str:
140
+ """
141
+ Infer a resource type and map ``ValueError`` to ``BadParameter``.
142
+
143
+ Parameters
144
+ ----------
145
+ value : str
146
+ The resource identifier (path, URL, or DSN).
147
+
148
+ Returns
149
+ -------
150
+ str
151
+ The inferred resource type: ``file``, ``api``, or ``database``.
152
+
153
+ Raises
154
+ ------
155
+ typer.BadParameter
156
+ If inference fails.
157
+ """
158
+ try:
159
+ return infer_resource_type(value)
160
+ except ValueError as exc: # pragma: no cover - exercised indirectly
161
+ raise typer.BadParameter(str(exc)) from exc
162
+
163
+
164
+ def infer_resource_type_soft(
165
+ value: str | None,
166
+ ) -> str | None:
167
+ """
168
+ Make a best-effort inference that tolerates inline payloads.
169
+
170
+ Parameters
171
+ ----------
172
+ value : str | None
173
+ The resource identifier (path, URL, DSN, or inline payload).
174
+
175
+ Returns
176
+ -------
177
+ str | None
178
+ The inferred resource type, or ``None`` if inference failed.
179
+ """
180
+ if value is None:
181
+ return None
182
+ try:
183
+ return infer_resource_type(value)
184
+ except ValueError:
185
+ return None
186
+
187
+
188
+ def log_inferred_resource(
189
+ state: CliState,
190
+ *,
191
+ role: str,
192
+ value: str,
193
+ resource_type: str | None,
194
+ ) -> None:
195
+ """
196
+ Emit a uniform verbose message for inferred resource types.
197
+
198
+ Parameters
199
+ ----------
200
+ state : CliState
201
+ The current CLI state.
202
+ role : str
203
+ The resource role, e.g., ``source`` or ``target``.
204
+ value : str
205
+ The resource identifier (path, URL, or DSN).
206
+ resource_type : str | None
207
+ The inferred resource type, or ``None`` if inference failed.
208
+ """
209
+ if state.quiet or not state.verbose or resource_type is None:
210
+ return
211
+ print(
212
+ f'Inferred {role}_type={resource_type} for {role}={value}',
213
+ file=sys.stderr,
214
+ )
215
+
216
+
217
+ def optional_choice(
218
+ value: str | None,
219
+ choices: Collection[str],
220
+ *,
221
+ label: str,
222
+ ) -> str | None:
223
+ """
224
+ Validate optional CLI choice inputs while preserving ``None``.
225
+
226
+ Parameters
227
+ ----------
228
+ value : str | None
229
+ The input value to validate, or ``None``.
230
+ choices : Collection[str]
231
+ The set of valid choices.
232
+ label : str
233
+ The label for error messages.
234
+
235
+ Returns
236
+ -------
237
+ str | None
238
+ The validated choice, or ``None`` if input was ``None``.
239
+ """
240
+ if value is None:
241
+ return None
242
+ return validate_choice(value, choices, label=label)
243
+
244
+
245
+ def resolve_resource_type(
246
+ *,
247
+ explicit_type: str | None,
248
+ override_type: str | None,
249
+ value: str,
250
+ label: str,
251
+ conflict_error: str | None = None,
252
+ legacy_file_error: str | None = None,
253
+ ) -> str:
254
+ """
255
+ Resolve resource type preference order and validate it.
256
+
257
+ Parameters
258
+ ----------
259
+ explicit_type : str | None
260
+ The explicit resource type from the CLI, or ``None`` if not provided.
261
+ override_type : str | None
262
+ The override resource type from the CLI, or ``None`` if not provided.
263
+ value : str
264
+ The resource identifier (path, URL, or DSN).
265
+ label : str
266
+ The label for error messages.
267
+ conflict_error : str | None, optional
268
+ The error message to raise if both explicit and override types are
269
+ provided, by default ``None``.
270
+ legacy_file_error : str | None, optional
271
+ The error message to raise if the explicit type is ``file``, by default
272
+ ``None``.
273
+
274
+ Returns
275
+ -------
276
+ str
277
+ The resolved and validated resource type.
278
+
279
+ Raises
280
+ ------
281
+ typer.BadParameter
282
+ If there is a conflict between explicit and override types, or if the
283
+ explicit type is ``file`` when disallowed.
284
+ """
285
+ if explicit_type is not None:
286
+ if override_type is not None and conflict_error:
287
+ raise typer.BadParameter(conflict_error)
288
+ if legacy_file_error and explicit_type.strip().lower() == 'file':
289
+ raise typer.BadParameter(legacy_file_error)
290
+ candidate = explicit_type
291
+ else:
292
+ candidate = override_type or infer_resource_type_or_exit(value)
293
+ return validate_choice(candidate, DATA_CONNECTORS, label=label)
294
+
295
+
296
+ def validate_choice(
297
+ value: str | object,
298
+ choices: Collection[str],
299
+ *,
300
+ label: str,
301
+ ) -> str:
302
+ """
303
+ Validate CLI input against a whitelist of choices.
304
+
305
+ Parameters
306
+ ----------
307
+ value : str
308
+ The input value to validate.
309
+ choices : Collection[str]
310
+ The set of valid choices.
311
+ label : str
312
+ The label for error messages.
313
+
314
+ Returns
315
+ -------
316
+ str
317
+ The validated choice.
318
+
319
+ Raises
320
+ ------
321
+ typer.BadParameter
322
+ If the input value is not in the set of valid choices.
323
+ """
324
+ v = str(value or '').strip().lower()
325
+ normalized_choices = {c.lower() for c in choices}
326
+ if v in normalized_choices:
327
+ # Preserve original casing from choices when possible for messages
328
+ for choice in choices:
329
+ if choice.lower() == v:
330
+ return choice
331
+ return v
332
+ allowed = ', '.join(sorted(choices))
333
+ raise typer.BadParameter(
334
+ f"Invalid {label} '{value}'. Choose from: {allowed}",
335
+ )
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']
@@ -190,6 +190,8 @@ class PipelineConfig:
190
190
  Target connectors, parsed tolerantly.
191
191
  jobs : list[JobConfig]
192
192
  Job orchestration definitions.
193
+ table_schemas : list[dict[str, Any]]
194
+ Optional DDL-style table specifications used by the render command.
193
195
  """
194
196
 
195
197
  # -- Attributes -- #
@@ -208,6 +210,7 @@ class PipelineConfig:
208
210
  transforms: dict[str, dict[str, Any]] = field(default_factory=dict)
209
211
  targets: list[Connector] = field(default_factory=list)
210
212
  jobs: list[JobConfig] = field(default_factory=list)
213
+ table_schemas: list[dict[str, Any]] = field(default_factory=list)
211
214
 
212
215
  # -- Class Methods -- #
213
216
 
@@ -312,6 +315,13 @@ class PipelineConfig:
312
315
  # Jobs
313
316
  jobs = _build_jobs(raw)
314
317
 
318
+ # Table schemas (optional, tolerant pass-through structures).
319
+ table_schemas: list[dict[str, Any]] = []
320
+ for entry in raw.get('table_schemas', []) or []:
321
+ spec = maybe_mapping(entry)
322
+ if spec is not None:
323
+ table_schemas.append(dict(spec))
324
+
315
325
  return cls(
316
326
  name=name,
317
327
  version=version,
@@ -325,4 +335,5 @@ class PipelineConfig:
325
335
  transforms=transforms,
326
336
  targets=targets,
327
337
  jobs=jobs,
338
+ table_schemas=table_schemas,
328
339
  )
@@ -0,0 +1,44 @@
1
+ """
2
+ :mod:`etlplus.database` package.
3
+
4
+ Database utilities for:
5
+ - DDL rendering and schema management.
6
+ - Schema parsing from configuration files.
7
+ - Dynamic ORM generation.
8
+ - Database engine/session management.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from .ddl import load_table_spec
14
+ from .ddl import render_table_sql
15
+ from .ddl import render_tables
16
+ from .ddl import render_tables_to_string
17
+ from .engine import engine
18
+ from .engine import load_database_url_from_config
19
+ from .engine import make_engine
20
+ from .engine import session
21
+ from .orm import Base
22
+ from .orm import build_models
23
+ from .orm import load_and_build_models
24
+ from .schema import load_table_specs
25
+
26
+ # SECTION: EXPORTS ========================================================== #
27
+
28
+
29
+ __all__ = [
30
+ # Functions
31
+ 'build_models',
32
+ 'load_and_build_models',
33
+ 'load_database_url_from_config',
34
+ 'load_table_spec',
35
+ 'load_table_specs',
36
+ 'make_engine',
37
+ 'render_table_sql',
38
+ 'render_tables',
39
+ 'render_tables_to_string',
40
+ 'Base',
41
+ # Singletons
42
+ 'engine',
43
+ 'session',
44
+ ]