etlplus 0.15.0__py3-none-any.whl → 0.15.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/ops/utils.py CHANGED
@@ -7,13 +7,11 @@ The helpers defined here embrace a "high cohesion, low coupling" design by
7
7
  isolating normalization, configuration, and logging responsibilities. The
8
8
  resulting surface keeps ``maybe_validate`` focused on orchestration while
9
9
  offloading ancillary concerns to composable helpers.
10
-
11
10
  """
12
11
 
13
12
  from __future__ import annotations
14
13
 
15
14
  from collections.abc import Callable
16
- from collections.abc import Mapping
17
15
  from dataclasses import dataclass
18
16
  from types import MappingProxyType
19
17
  from typing import Any
@@ -23,7 +21,7 @@ from typing import TypedDict
23
21
  from typing import cast
24
22
 
25
23
  from ..types import StrAnyMap
26
- from ..utils import normalized_str
24
+ from ..utils import normalize_choice
27
25
 
28
26
  # SECTION: TYPED DICTIONARIES =============================================== #
29
27
 
@@ -320,7 +318,7 @@ def _normalize_phase(
320
318
  """
321
319
  return cast(
322
320
  ValidationPhase,
323
- _normalize_choice(
321
+ normalize_choice(
324
322
  value,
325
323
  mapping=_PHASE_CHOICES,
326
324
  default='before_transform',
@@ -346,7 +344,7 @@ def _normalize_severity(
346
344
  """
347
345
  return cast(
348
346
  ValidationSeverity,
349
- _normalize_choice(
347
+ normalize_choice(
350
348
  value,
351
349
  mapping=_SEVERITY_CHOICES,
352
350
  default='error',
@@ -372,7 +370,7 @@ def _normalize_window(
372
370
  """
373
371
  return cast(
374
372
  ValidationWindow,
375
- _normalize_choice(
373
+ normalize_choice(
376
374
  value,
377
375
  mapping=_WINDOW_CHOICES,
378
376
  default='both',
@@ -380,33 +378,6 @@ def _normalize_window(
380
378
  )
381
379
 
382
380
 
383
- def _normalize_choice(
384
- value: str | None,
385
- *,
386
- mapping: Mapping[str, str],
387
- default: str,
388
- ) -> str:
389
- """
390
- Normalize a text value against a mapping with a default fallback.
391
-
392
- Parameters
393
- ----------
394
- value : str | None
395
- Input text to normalize.
396
- mapping : Mapping[str, str]
397
- Mapping of accepted values to normalized outputs.
398
- default : str
399
- Default to return when input is missing or unrecognized.
400
-
401
- Returns
402
- -------
403
- str
404
- Normalized value.
405
- """
406
- normalized = normalized_str(value)
407
- return mapping.get(normalized, default)
408
-
409
-
410
381
  def _rule_name(
411
382
  rules: Ruleset,
412
383
  ) -> str | None:
etlplus/ops/validate.py CHANGED
@@ -11,8 +11,8 @@ Highlights
11
11
  ----------
12
12
  - Centralized type map and helpers for clarity and reuse.
13
13
  - Consistent error wording; field and item paths like ``[2].email``.
14
- - Small, focused public API with ``load_data``, ``validate_field``,
15
- ``validate``.
14
+ - Small, focused public API with :func:`load_data`, :func:`validate_field`,
15
+ :func:`validate`.
16
16
 
17
17
  Examples
18
18
  --------
@@ -66,7 +66,7 @@ TYPE_MAP: Final[dict[str, type | tuple[type, ...]]] = {
66
66
  }
67
67
 
68
68
 
69
- # SECTION: CLASSES ========================================================== #
69
+ # SECTION: TYPED DICTS ====================================================== #
70
70
 
71
71
 
72
72
  class FieldRules(TypedDict, total=False):
etlplus/types.py CHANGED
@@ -11,8 +11,9 @@ Notes
11
11
 
12
12
  See Also
13
13
  --------
14
- - :mod:`etlplus.api.types` for HTTP-specific aliases
15
- - :mod:`etlplus.config.types` for TypedDict surfaces
14
+ - :mod:`etlplus.api.types` for HTTP-specific aliases and data classes
15
+ - :mod:`etlplus.workflow.types` for workflow-specific aliases and TypedDict
16
+ surfaces
16
17
 
17
18
  Examples
18
19
  --------
etlplus/utils.py CHANGED
@@ -8,6 +8,7 @@ from __future__ import annotations
8
8
 
9
9
  import json
10
10
  from collections.abc import Callable
11
+ from collections.abc import Iterable
11
12
  from collections.abc import Mapping
12
13
  from typing import Any
13
14
  from typing import TypeVar
@@ -25,6 +26,7 @@ __all__ = [
25
26
  # Mapping utilities
26
27
  'cast_str_dict',
27
28
  'coerce_dict',
29
+ 'deep_substitute',
28
30
  'maybe_mapping',
29
31
  # Float coercion
30
32
  'to_float',
@@ -39,7 +41,8 @@ __all__ = [
39
41
  # Generic number coercion
40
42
  'to_number',
41
43
  # Text processing
42
- 'normalized_str',
44
+ 'normalize_choice',
45
+ 'normalize_str',
43
46
  ]
44
47
 
45
48
 
@@ -56,6 +59,52 @@ Num = TypeVar('Num', int, float)
56
59
  # -- Data Utilities -- #
57
60
 
58
61
 
62
+ def deep_substitute(
63
+ value: Any,
64
+ vars_map: StrAnyMap | None,
65
+ env_map: Mapping[str, str] | None,
66
+ ) -> Any:
67
+ """
68
+ Recursively substitute ``${VAR}`` tokens in nested structures.
69
+
70
+ Only strings are substituted; other types are returned as-is.
71
+
72
+ Parameters
73
+ ----------
74
+ value : Any
75
+ The value to perform substitutions on.
76
+ vars_map : StrAnyMap | None
77
+ Mapping of variable names to replacement values (lower precedence).
78
+ env_map : Mapping[str, str] | None
79
+ Mapping of environment variables overriding ``vars_map`` values
80
+ (higher precedence).
81
+
82
+ Returns
83
+ -------
84
+ Any
85
+ New structure with substitutions applied where tokens were found.
86
+ """
87
+ substitutions = _prepare_substitutions(vars_map, env_map)
88
+
89
+ def _apply(node: Any) -> Any:
90
+ match node:
91
+ case str():
92
+ return _replace_tokens(node, substitutions)
93
+ case Mapping():
94
+ return {k: _apply(v) for k, v in node.items()}
95
+ case list() | tuple() as seq:
96
+ apply = [_apply(item) for item in seq]
97
+ return apply if isinstance(seq, list) else tuple(apply)
98
+ case set():
99
+ return {_apply(item) for item in node}
100
+ case frozenset():
101
+ return frozenset(_apply(item) for item in node)
102
+ case _:
103
+ return node
104
+
105
+ return _apply(value)
106
+
107
+
59
108
  def cast_str_dict(
60
109
  mapping: StrAnyMap | None,
61
110
  ) -> dict[str, str]:
@@ -372,7 +421,7 @@ def to_number(
372
421
  # -- Text Processing -- #
373
422
 
374
423
 
375
- def normalized_str(
424
+ def normalize_str(
376
425
  value: str | None,
377
426
  ) -> str:
378
427
  """
@@ -392,6 +441,36 @@ def normalized_str(
392
441
  return (value or '').strip().lower()
393
442
 
394
443
 
444
+ def normalize_choice(
445
+ value: str | None,
446
+ *,
447
+ mapping: Mapping[str, str],
448
+ default: str,
449
+ normalize: Callable[[str | None], str] = normalize_str,
450
+ ) -> str:
451
+ """
452
+ Normalize a string choice using a mapping and fallback.
453
+
454
+ Parameters
455
+ ----------
456
+ value : str | None
457
+ Input value to normalize.
458
+ mapping : Mapping[str, str]
459
+ Mapping of acceptable normalized inputs to output values.
460
+ default : str
461
+ Default return value when input is missing or unrecognized.
462
+ normalize : Callable[[str | None], str], optional
463
+ Normalization function applied to *value*. Defaults to
464
+ :func:`normalize_str`.
465
+
466
+ Returns
467
+ -------
468
+ str
469
+ Normalized mapped value or ``default``.
470
+ """
471
+ return mapping.get(normalize(value), default)
472
+
473
+
395
474
  # SECTION: INTERNAL FUNCTIONS =============================================== #
396
475
 
397
476
 
@@ -425,6 +504,61 @@ def _clamp(
425
504
  return value
426
505
 
427
506
 
507
+ def _prepare_substitutions(
508
+ vars_map: StrAnyMap | None,
509
+ env_map: Mapping[str, Any] | None,
510
+ ) -> tuple[tuple[str, Any], ...]:
511
+ """
512
+ Merge variable and environment maps into an ordered substitutions list.
513
+
514
+ Parameters
515
+ ----------
516
+ vars_map : StrAnyMap | None
517
+ Mapping of variable names to replacement values (lower precedence).
518
+ env_map : Mapping[str, Any] | None
519
+ Environment-backed values that override entries from ``vars_map``.
520
+
521
+ Returns
522
+ -------
523
+ tuple[tuple[str, Any], ...]
524
+ Immutable sequence of ``(name, value)`` pairs suitable for token
525
+ replacement.
526
+ """
527
+ if not vars_map and not env_map:
528
+ return ()
529
+ merged: dict[str, Any] = {**(vars_map or {}), **(env_map or {})}
530
+ return tuple(merged.items())
531
+
532
+
533
+ def _replace_tokens(
534
+ text: str,
535
+ substitutions: Iterable[tuple[str, Any]],
536
+ ) -> str:
537
+ """
538
+ Replace ``${VAR}`` tokens in ``text`` using ``substitutions``.
539
+
540
+ Parameters
541
+ ----------
542
+ text : str
543
+ Input string that may contain ``${VAR}`` tokens.
544
+ substitutions : Iterable[tuple[str, Any]]
545
+ Sequence of ``(name, value)`` pairs used for token replacement.
546
+
547
+ Returns
548
+ -------
549
+ str
550
+ Updated text with replacements applied.
551
+ """
552
+ if not substitutions:
553
+ return text
554
+ out = text
555
+ for name, replacement in substitutions:
556
+ token = f'${{{name}}}'
557
+ if token in out:
558
+ out = out.replace(token, str(replacement))
559
+ return out
560
+
561
+
428
562
  def _coerce_float(
429
563
  value: object,
430
564
  ) -> float | None:
@@ -22,15 +22,15 @@ Notes
22
22
  -----
23
23
  - TypedDict shapes are editor hints; runtime parsing remains permissive
24
24
  (from_obj accepts Mapping[str, Any]).
25
- - TypedDicts referenced in :mod:`etlplus.config.types` remain editor hints.
25
+ - TypedDicts referenced in :mod:`etlplus.workflow.types` remain editor hints.
26
26
  Runtime parsing stays permissive and tolerant.
27
27
 
28
28
  See Also
29
29
  --------
30
30
  - TypedDict shapes for editor hints (not enforced at runtime):
31
- :mod:`etlplus.config.types.ConnectorApiConfigMap`,
32
- :mod:`etlplus.config.types.ConnectorDbConfigMap`,
33
- :mod:`etlplus.config.types.ConnectorFileConfigMap`.
31
+ :mod:`etlplus.workflow.types.ConnectorApiConfigMap`,
32
+ :mod:`etlplus.workflow.types.ConnectorDbConfigMap`,
33
+ :mod:`etlplus.workflow.types.ConnectorFileConfigMap`.
34
34
  """
35
35
 
36
36
  from __future__ import annotations
@@ -71,6 +71,40 @@ __all__ = [
71
71
  ]
72
72
 
73
73
 
74
+ # SECTION: INTERNAL FUNCTIONS ============================================== #
75
+
76
+
77
+ def _require_name(
78
+ obj: StrAnyMap,
79
+ *,
80
+ kind: str,
81
+ ) -> str:
82
+ """
83
+ Extract and validate the ``name`` field from connector mappings.
84
+
85
+ Parameters
86
+ ----------
87
+ obj : StrAnyMap
88
+ Connector mapping with a ``name`` entry.
89
+ kind : str
90
+ Connector kind used in the error message.
91
+
92
+ Returns
93
+ -------
94
+ str
95
+ Valid connector name.
96
+
97
+ Raises
98
+ ------
99
+ TypeError
100
+ If ``name`` is missing or not a string.
101
+ """
102
+ name = obj.get('name')
103
+ if not isinstance(name, str):
104
+ raise TypeError(f'Connector{kind} requires a "name" (str)')
105
+ return name
106
+
107
+
74
108
  # SECTION: DATA CLASSES ===================================================== #
75
109
 
76
110
 
@@ -151,15 +185,8 @@ class ConnectorApi:
151
185
  -------
152
186
  Self
153
187
  Parsed connector instance.
154
-
155
- Raises
156
- ------
157
- TypeError
158
- If ``name`` is missing or invalid.
159
188
  """
160
- name = obj.get('name')
161
- if not isinstance(name, str):
162
- raise TypeError('ConnectorApi requires a "name" (str)')
189
+ name = _require_name(obj, kind='Api')
163
190
  headers = cast_str_dict(obj.get('headers'))
164
191
 
165
192
  return cls(
@@ -233,15 +260,8 @@ class ConnectorDb:
233
260
  -------
234
261
  Self
235
262
  Parsed connector instance.
236
-
237
- Raises
238
- ------
239
- TypeError
240
- If ``name`` is missing or invalid.
241
263
  """
242
- name = obj.get('name')
243
- if not isinstance(name, str):
244
- raise TypeError('ConnectorDb requires a "name" (str)')
264
+ name = _require_name(obj, kind='Db')
245
265
 
246
266
  return cls(
247
267
  name=name,
@@ -307,15 +327,8 @@ class ConnectorFile:
307
327
  -------
308
328
  Self
309
329
  Parsed connector instance.
310
-
311
- Raises
312
- ------
313
- TypeError
314
- If ``name`` is missing or invalid.
315
330
  """
316
- name = obj.get('name')
317
- if not isinstance(name, str):
318
- raise TypeError('ConnectorFile requires a "name" (str)')
331
+ name = _require_name(obj, kind='File')
319
332
 
320
333
  return cls(
321
334
  name=name,
etlplus/workflow/jobs.py CHANGED
@@ -19,6 +19,7 @@ from dataclasses import field
19
19
  from typing import Any
20
20
  from typing import Self
21
21
 
22
+ from ..types import StrAnyMap
22
23
  from ..utils import coerce_dict
23
24
  from ..utils import maybe_mapping
24
25
 
@@ -35,6 +36,75 @@ __all__ = [
35
36
  ]
36
37
 
37
38
 
39
+ # SECTION: INTERNAL FUNCTIONS =============================================== #
40
+
41
+
42
+ def _coerce_optional_str(value: Any) -> str | None:
43
+ """
44
+ Normalize optional string values, coercing non-strings when needed.
45
+
46
+ Parameters
47
+ ----------
48
+ value : Any
49
+ Optional value to normalize.
50
+
51
+ Returns
52
+ -------
53
+ str | None
54
+ ``None`` when ``value`` is ``None``; otherwise a string value.
55
+ """
56
+ if value is None:
57
+ return None
58
+ return value if isinstance(value, str) else str(value)
59
+
60
+
61
+ def _parse_depends_on(
62
+ value: Any,
63
+ ) -> list[str]:
64
+ """
65
+ Normalize dependency declarations into a string list.
66
+
67
+ Parameters
68
+ ----------
69
+ value : Any
70
+ Input dependency specification (string or list of strings).
71
+
72
+ Returns
73
+ -------
74
+ list[str]
75
+ Normalized dependency list.
76
+ """
77
+ if isinstance(value, str):
78
+ return [value]
79
+ if isinstance(value, list):
80
+ return [entry for entry in value if isinstance(entry, str)]
81
+ return []
82
+
83
+
84
+ def _require_str(
85
+ # data: dict[str, Any],
86
+ data: StrAnyMap,
87
+ key: str,
88
+ ) -> str | None:
89
+ """
90
+ Extract a required string field from a mapping.
91
+
92
+ Parameters
93
+ ----------
94
+ data : StrAnyMap
95
+ Mapping containing the target field.
96
+ key : str
97
+ Field name to extract.
98
+
99
+ Returns
100
+ -------
101
+ str | None
102
+ The string value when present and valid; otherwise ``None``.
103
+ """
104
+ value = data.get(key)
105
+ return value if isinstance(value, str) else None
106
+
107
+
38
108
  # SECTION: DATA CLASSES ===================================================== #
39
109
 
40
110
 
@@ -79,8 +149,8 @@ class ExtractRef:
79
149
  data = maybe_mapping(obj)
80
150
  if not data:
81
151
  return None
82
- source = data.get('source')
83
- if not isinstance(source, str):
152
+ source = _require_str(data, 'source')
153
+ if source is None:
84
154
  return None
85
155
  return cls(
86
156
  source=source,
@@ -144,22 +214,13 @@ class JobConfig:
144
214
  data = maybe_mapping(obj)
145
215
  if not data:
146
216
  return None
147
- name = data.get('name')
148
- if not isinstance(name, str):
217
+ name = _require_str(data, 'name')
218
+ if name is None:
149
219
  return None
150
220
 
151
- description = data.get('description')
152
- if description is not None and not isinstance(description, str):
153
- description = str(description)
221
+ description = _coerce_optional_str(data.get('description'))
154
222
 
155
- depends_raw = data.get('depends_on')
156
- depends_on: list[str] = []
157
- if isinstance(depends_raw, str):
158
- depends_on = [depends_raw]
159
- elif isinstance(depends_raw, list):
160
- for entry in depends_raw:
161
- if isinstance(entry, str):
162
- depends_on.append(entry)
223
+ depends_on = _parse_depends_on(data.get('depends_on'))
163
224
 
164
225
  return cls(
165
226
  name=name,
@@ -213,8 +274,8 @@ class LoadRef:
213
274
  data = maybe_mapping(obj)
214
275
  if not data:
215
276
  return None
216
- target = data.get('target')
217
- if not isinstance(target, str):
277
+ target = _require_str(data, 'target')
278
+ if target is None:
218
279
  return None
219
280
  return cls(
220
281
  target=target,
@@ -260,8 +321,8 @@ class TransformRef:
260
321
  data = maybe_mapping(obj)
261
322
  if not data:
262
323
  return None
263
- pipeline = data.get('pipeline')
264
- if not isinstance(pipeline, str):
324
+ pipeline = _require_str(data, 'pipeline')
325
+ if pipeline is None:
265
326
  return None
266
327
  return cls(pipeline=pipeline)
267
328
 
@@ -311,15 +372,11 @@ class ValidationRef:
311
372
  data = maybe_mapping(obj)
312
373
  if not data:
313
374
  return None
314
- ruleset = data.get('ruleset')
315
- if not isinstance(ruleset, str):
375
+ ruleset = _require_str(data, 'ruleset')
376
+ if ruleset is None:
316
377
  return None
317
- severity = data.get('severity')
318
- if severity is not None and not isinstance(severity, str):
319
- severity = str(severity)
320
- phase = data.get('phase')
321
- if phase is not None and not isinstance(phase, str):
322
- phase = str(phase)
378
+ severity = _coerce_optional_str(data.get('severity'))
379
+ phase = _coerce_optional_str(data.get('phase'))
323
380
  return cls(
324
381
  ruleset=ruleset,
325
382
  severity=severity,