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/cli/state.py ADDED
@@ -0,0 +1,336 @@
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. '
134
+ 'Use --source-type/--target-type to specify it.',
135
+ )
136
+
137
+
138
+ def infer_resource_type_or_exit(
139
+ value: str,
140
+ ) -> str:
141
+ """
142
+ Infer a resource type and map ``ValueError`` to ``BadParameter``.
143
+
144
+ Parameters
145
+ ----------
146
+ value : str
147
+ The resource identifier (path, URL, or DSN).
148
+
149
+ Returns
150
+ -------
151
+ str
152
+ The inferred resource type: ``file``, ``api``, or ``database``.
153
+
154
+ Raises
155
+ ------
156
+ typer.BadParameter
157
+ If inference fails.
158
+ """
159
+ try:
160
+ return infer_resource_type(value)
161
+ except ValueError as exc: # pragma: no cover - exercised indirectly
162
+ raise typer.BadParameter(str(exc)) from exc
163
+
164
+
165
+ def infer_resource_type_soft(
166
+ value: str | None,
167
+ ) -> str | None:
168
+ """
169
+ Make a best-effort inference that tolerates inline payloads.
170
+
171
+ Parameters
172
+ ----------
173
+ value : str | None
174
+ The resource identifier (path, URL, DSN, or inline payload).
175
+
176
+ Returns
177
+ -------
178
+ str | None
179
+ The inferred resource type, or ``None`` if inference failed.
180
+ """
181
+ if value is None:
182
+ return None
183
+ try:
184
+ return infer_resource_type(value)
185
+ except ValueError:
186
+ return None
187
+
188
+
189
+ def log_inferred_resource(
190
+ state: CliState,
191
+ *,
192
+ role: str,
193
+ value: str,
194
+ resource_type: str | None,
195
+ ) -> None:
196
+ """
197
+ Emit a uniform verbose message for inferred resource types.
198
+
199
+ Parameters
200
+ ----------
201
+ state : CliState
202
+ The current CLI state.
203
+ role : str
204
+ The resource role, e.g., ``source`` or ``target``.
205
+ value : str
206
+ The resource identifier (path, URL, or DSN).
207
+ resource_type : str | None
208
+ The inferred resource type, or ``None`` if inference failed.
209
+ """
210
+ if state.quiet or not state.verbose or resource_type is None:
211
+ return
212
+ print(
213
+ f'Inferred {role}_type={resource_type} for {role}={value}',
214
+ file=sys.stderr,
215
+ )
216
+
217
+
218
+ def optional_choice(
219
+ value: str | None,
220
+ choices: Collection[str],
221
+ *,
222
+ label: str,
223
+ ) -> str | None:
224
+ """
225
+ Validate optional CLI choice inputs while preserving ``None``.
226
+
227
+ Parameters
228
+ ----------
229
+ value : str | None
230
+ The input value to validate, or ``None``.
231
+ choices : Collection[str]
232
+ The set of valid choices.
233
+ label : str
234
+ The label for error messages.
235
+
236
+ Returns
237
+ -------
238
+ str | None
239
+ The validated choice, or ``None`` if input was ``None``.
240
+ """
241
+ if value is None:
242
+ return None
243
+ return validate_choice(value, choices, label=label)
244
+
245
+
246
+ def resolve_resource_type(
247
+ *,
248
+ explicit_type: str | None,
249
+ override_type: str | None,
250
+ value: str,
251
+ label: str,
252
+ conflict_error: str | None = None,
253
+ legacy_file_error: str | None = None,
254
+ ) -> str:
255
+ """
256
+ Resolve resource type preference order and validate it.
257
+
258
+ Parameters
259
+ ----------
260
+ explicit_type : str | None
261
+ The explicit resource type from the CLI, or ``None`` if not provided.
262
+ override_type : str | None
263
+ The override resource type from the CLI, or ``None`` if not provided.
264
+ value : str
265
+ The resource identifier (path, URL, or DSN).
266
+ label : str
267
+ The label for error messages.
268
+ conflict_error : str | None, optional
269
+ The error message to raise if both explicit and override types are
270
+ provided, by default ``None``.
271
+ legacy_file_error : str | None, optional
272
+ The error message to raise if the explicit type is ``file``, by default
273
+ ``None``.
274
+
275
+ Returns
276
+ -------
277
+ str
278
+ The resolved and validated resource type.
279
+
280
+ Raises
281
+ ------
282
+ typer.BadParameter
283
+ If there is a conflict between explicit and override types, or if the
284
+ explicit type is ``file`` when disallowed.
285
+ """
286
+ if explicit_type is not None:
287
+ if override_type is not None and conflict_error:
288
+ raise typer.BadParameter(conflict_error)
289
+ if legacy_file_error and explicit_type.strip().lower() == 'file':
290
+ raise typer.BadParameter(legacy_file_error)
291
+ candidate = explicit_type
292
+ else:
293
+ candidate = override_type or infer_resource_type_or_exit(value)
294
+ return validate_choice(candidate, DATA_CONNECTORS, label=label)
295
+
296
+
297
+ def validate_choice(
298
+ value: str | object,
299
+ choices: Collection[str],
300
+ *,
301
+ label: str,
302
+ ) -> str:
303
+ """
304
+ Validate CLI input against a whitelist of choices.
305
+
306
+ Parameters
307
+ ----------
308
+ value : str | object
309
+ The input value to validate.
310
+ choices : Collection[str]
311
+ The set of valid choices.
312
+ label : str
313
+ The label for error messages.
314
+
315
+ Returns
316
+ -------
317
+ str
318
+ The validated choice.
319
+
320
+ Raises
321
+ ------
322
+ typer.BadParameter
323
+ If the input value is not in the set of valid choices.
324
+ """
325
+ v = str(value or '').strip().lower()
326
+ normalized_choices = {c.lower() for c in choices}
327
+ if v in normalized_choices:
328
+ # Preserve original casing from choices when possible for messages
329
+ for choice in choices:
330
+ if choice.lower() == v:
331
+ return choice
332
+ return v
333
+ allowed = ', '.join(sorted(choices))
334
+ raise typer.BadParameter(
335
+ f"Invalid {label} '{value}'. Choose from: {allowed}",
336
+ )
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']
@@ -18,6 +18,7 @@ from .engine import engine
18
18
  from .engine import load_database_url_from_config
19
19
  from .engine import make_engine
20
20
  from .engine import session
21
+ from .orm import Base
21
22
  from .orm import build_models
22
23
  from .orm import load_and_build_models
23
24
  from .schema import load_table_specs
@@ -36,6 +37,7 @@ __all__ = [
36
37
  'render_table_sql',
37
38
  'render_tables',
38
39
  'render_tables_to_string',
40
+ 'Base',
39
41
  # Singletons
40
42
  'engine',
41
43
  'session',
etlplus/database/ddl.py CHANGED
@@ -15,7 +15,6 @@ import os
15
15
  from collections.abc import Iterable
16
16
  from collections.abc import Mapping
17
17
  from pathlib import Path
18
- from typing import Any
19
18
  from typing import Final
20
19
 
21
20
  from jinja2 import DictLoader
@@ -24,6 +23,9 @@ from jinja2 import FileSystemLoader
24
23
  from jinja2 import StrictUndefined
25
24
 
26
25
  from ..file import File
26
+ from ..types import StrAnyMap
27
+ from ..types import StrPath
28
+ from ..types import TemplateKey
27
29
 
28
30
  # SECTION: EXPORTS ========================================================== #
29
31
 
@@ -52,7 +54,7 @@ _SUPPORTED_SPEC_SUFFIXES: Final[frozenset[str]] = frozenset(
52
54
  # SECTION: CONSTANTS ======================================================== #
53
55
 
54
56
 
55
- TEMPLATES: Final[dict[str, str]] = {
57
+ TEMPLATES: Final[dict[TemplateKey, str]] = {
56
58
  'ddl': 'ddl.sql.j2',
57
59
  'view': 'view.sql.j2',
58
60
  }
@@ -64,7 +66,8 @@ TEMPLATES: Final[dict[str, str]] = {
64
66
  def _load_template_text(
65
67
  filename: str,
66
68
  ) -> str:
67
- """Return the bundled template text.
69
+ """
70
+ Return the bundled template text.
68
71
 
69
72
  Parameters
70
73
  ----------
@@ -99,16 +102,17 @@ def _load_template_text(
99
102
 
100
103
  def _resolve_template(
101
104
  *,
102
- template_key: str | None,
103
- template_path: str | None,
105
+ template_key: TemplateKey | None,
106
+ template_path: StrPath | None,
104
107
  ) -> tuple[Environment, str]:
105
- """Return environment and template name for rendering.
108
+ """
109
+ Return environment and template name for rendering.
106
110
 
107
111
  Parameters
108
112
  ----------
109
- template_key : str | None
113
+ template_key : TemplateKey | None
110
114
  Named template key bundled with the package.
111
- template_path : str | None
115
+ template_path : StrPath | None
112
116
  Explicit template file override.
113
117
 
114
118
  Returns
@@ -123,7 +127,11 @@ def _resolve_template(
123
127
  ValueError
124
128
  If the template key is unknown.
125
129
  """
126
- file_override = template_path or os.environ.get('TEMPLATE_NAME')
130
+ file_override = (
131
+ str(template_path)
132
+ if template_path is not None
133
+ else os.environ.get('TEMPLATE_NAME')
134
+ )
127
135
  if file_override:
128
136
  path = Path(file_override)
129
137
  if not path.exists():
@@ -137,14 +145,14 @@ def _resolve_template(
137
145
  )
138
146
  return env, path.name
139
147
 
140
- key = (template_key or 'ddl').strip()
148
+ key: TemplateKey = template_key or 'ddl'
141
149
  if key not in TEMPLATES:
142
150
  choices = ', '.join(sorted(TEMPLATES))
143
151
  raise ValueError(
144
152
  f'Unknown template key "{key}". Choose from: {choices}',
145
153
  )
146
154
 
147
- # Load template from package data
155
+ # Load template from package data.
148
156
  template_filename = TEMPLATES[key]
149
157
  template_source = _load_template_text(template_filename)
150
158
 
@@ -161,19 +169,19 @@ def _resolve_template(
161
169
 
162
170
 
163
171
  def load_table_spec(
164
- path: Path | str,
165
- ) -> dict[str, Any]:
172
+ path: StrPath,
173
+ ) -> StrAnyMap:
166
174
  """
167
175
  Load a table specification from disk.
168
176
 
169
177
  Parameters
170
178
  ----------
171
- path : Path | str
179
+ path : StrPath
172
180
  Path to the JSON or YAML specification file.
173
181
 
174
182
  Returns
175
183
  -------
176
- dict[str, Any]
184
+ StrAnyMap
177
185
  Parsed table specification mapping.
178
186
 
179
187
  Raises
@@ -210,9 +218,9 @@ def load_table_spec(
210
218
 
211
219
 
212
220
  def render_table_sql(
213
- spec: Mapping[str, Any],
221
+ spec: StrAnyMap,
214
222
  *,
215
- template: str | None = 'ddl',
223
+ template: TemplateKey | None = 'ddl',
216
224
  template_path: str | None = None,
217
225
  ) -> str:
218
226
  """
@@ -220,9 +228,9 @@ def render_table_sql(
220
228
 
221
229
  Parameters
222
230
  ----------
223
- spec : Mapping[str, Any]
231
+ spec : StrAnyMap
224
232
  Table specification mapping.
225
- template : str | None, optional
233
+ template : TemplateKey | None, optional
226
234
  Template key to use (default: 'ddl').
227
235
  template_path : str | None, optional
228
236
  Path to a custom template file (overrides ``template``).
@@ -241,9 +249,9 @@ def render_table_sql(
241
249
 
242
250
 
243
251
  def render_tables(
244
- specs: Iterable[Mapping[str, Any]],
252
+ specs: Iterable[StrAnyMap],
245
253
  *,
246
- template: str | None = 'ddl',
254
+ template: TemplateKey | None = 'ddl',
247
255
  template_path: str | None = None,
248
256
  ) -> list[str]:
249
257
  """
@@ -251,9 +259,9 @@ def render_tables(
251
259
 
252
260
  Parameters
253
261
  ----------
254
- specs : Iterable[Mapping[str, Any]]
262
+ specs : Iterable[StrAnyMap]
255
263
  Table specification mappings.
256
- template : str | None, optional
264
+ template : TemplateKey | None, optional
257
265
  Template key to use (default: 'ddl').
258
266
  template_path : str | None, optional
259
267
  Path to a custom template file (overrides ``template``).
@@ -271,21 +279,21 @@ def render_tables(
271
279
 
272
280
 
273
281
  def render_tables_to_string(
274
- spec_paths: Iterable[Path | str],
282
+ spec_paths: Iterable[StrPath],
275
283
  *,
276
- template: str | None = 'ddl',
277
- template_path: Path | str | None = None,
284
+ template: TemplateKey | None = 'ddl',
285
+ template_path: StrPath | None = None,
278
286
  ) -> str:
279
287
  """
280
288
  Render one or more specs and concatenate the SQL payloads.
281
289
 
282
290
  Parameters
283
291
  ----------
284
- spec_paths : Iterable[Path | str]
292
+ spec_paths : Iterable[StrPath]
285
293
  Paths to table specification files.
286
- template : str | None, optional
294
+ template : TemplateKey | None, optional
287
295
  Template key bundled with ETLPlus. Defaults to ``'ddl'``.
288
- template_path : Path | str | None, optional
296
+ template_path : StrPath | None, optional
289
297
  Custom Jinja template to override the bundled templates.
290
298
 
291
299
  Returns
@@ -10,12 +10,15 @@ import os
10
10
  from collections.abc import Mapping
11
11
  from pathlib import Path
12
12
  from typing import Any
13
+ from typing import Final
13
14
 
14
15
  from sqlalchemy import create_engine
15
16
  from sqlalchemy.engine import Engine
16
17
  from sqlalchemy.orm import sessionmaker
17
18
 
18
19
  from ..file import File
20
+ from ..types import StrAnyMap
21
+ from ..types import StrPath
19
22
 
20
23
  # SECTION: EXPORTS ========================================================== #
21
24
 
@@ -33,7 +36,7 @@ __all__ = [
33
36
  # SECTION: INTERNAL CONSTANTS =============================================== #
34
37
 
35
38
 
36
- DATABASE_URL: str = (
39
+ DATABASE_URL: Final[str] = (
37
40
  os.getenv('DATABASE_URL')
38
41
  or os.getenv('DATABASE_DSN')
39
42
  or 'sqlite+pysqlite:///:memory:'
@@ -43,13 +46,15 @@ DATABASE_URL: str = (
43
46
  # SECTION: INTERNAL FUNCTIONS =============================================== #
44
47
 
45
48
 
46
- def _resolve_url_from_mapping(cfg: Mapping[str, Any]) -> str | None:
49
+ def _resolve_url_from_mapping(
50
+ cfg: StrAnyMap,
51
+ ) -> str | None:
47
52
  """
48
53
  Return a URL/DSN from a mapping if present.
49
54
 
50
55
  Parameters
51
56
  ----------
52
- cfg : Mapping[str, Any]
57
+ cfg : StrAnyMap
53
58
  Configuration mapping potentially containing connection fields.
54
59
 
55
60
  Returns
@@ -74,7 +79,7 @@ def _resolve_url_from_mapping(cfg: Mapping[str, Any]) -> str | None:
74
79
 
75
80
 
76
81
  def load_database_url_from_config(
77
- path: str | Path,
82
+ path: StrPath,
78
83
  *,
79
84
  name: str | None = None,
80
85
  ) -> str:
@@ -88,7 +93,7 @@ def load_database_url_from_config(
88
93
 
89
94
  Parameters
90
95
  ----------
91
- path : str | Path
96
+ path : StrPath
92
97
  Location of the configuration file.
93
98
  name : str | None, optional
94
99
  Named database entry under the ``databases`` map (default: