weevr-core 1.18.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.
- weevr/config/__init__.py +265 -0
- weevr/config/audit_templates.py +314 -0
- weevr/config/duration.py +5 -0
- weevr/config/inheritance.py +402 -0
- weevr/config/locations.py +492 -0
- weevr/config/macros.py +71 -0
- weevr/config/parser.py +213 -0
- weevr/config/resolver.py +750 -0
- weevr/config/validation.py +312 -0
- weevr/config/warp.py +223 -0
- weevr/duration.py +48 -0
- weevr/engine/formatting.py +23 -0
- weevr/engine/planner.py +513 -0
- weevr/errors/__init__.py +43 -0
- weevr/errors/exceptions.py +422 -0
- weevr/model/__init__.py +168 -0
- weevr/model/audit.py +29 -0
- weevr/model/base.py +13 -0
- weevr/model/column_set.py +343 -0
- weevr/model/connection.py +29 -0
- weevr/model/dimension.py +377 -0
- weevr/model/execution.py +38 -0
- weevr/model/export.py +143 -0
- weevr/model/fact.py +67 -0
- weevr/model/failure.py +26 -0
- weevr/model/hooks.py +227 -0
- weevr/model/keys.py +98 -0
- weevr/model/load.py +209 -0
- weevr/model/lookup.py +95 -0
- weevr/model/loom.py +134 -0
- weevr/model/naming.py +78 -0
- weevr/model/params.py +44 -0
- weevr/model/pipeline.py +1085 -0
- weevr/model/seed.py +44 -0
- weevr/model/source.py +184 -0
- weevr/model/sub_pipeline.py +42 -0
- weevr/model/target.py +217 -0
- weevr/model/thread.py +242 -0
- weevr/model/types.py +12 -0
- weevr/model/validation.py +132 -0
- weevr/model/variable.py +27 -0
- weevr/model/warp.py +182 -0
- weevr/model/weave.py +187 -0
- weevr/model/write.py +73 -0
- weevr/reserved_words.py +442 -0
- weevr/result.py +637 -0
- weevr/telemetry/events.py +79 -0
- weevr/telemetry/results.py +181 -0
- weevr/telemetry/span.py +65 -0
- weevr/telemetry/trace.py +66 -0
- weevr_core-1.18.0.dist-info/METADATA +39 -0
- weevr_core-1.18.0.dist-info/RECORD +54 -0
- weevr_core-1.18.0.dist-info/WHEEL +4 -0
- weevr_core-1.18.0.dist-info/licenses/LICENSE +201 -0
weevr/config/__init__.py
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""Configuration loading and validation."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pydantic import ValidationError
|
|
7
|
+
|
|
8
|
+
from weevr.config.inheritance import apply_inheritance
|
|
9
|
+
from weevr.config.locations import ConfigLocation, LocalConfigLocation, make_location
|
|
10
|
+
from weevr.config.macros import expand_foreach
|
|
11
|
+
from weevr.config.parser import (
|
|
12
|
+
detect_config_type,
|
|
13
|
+
detect_config_type_from_extension,
|
|
14
|
+
extract_config_version,
|
|
15
|
+
parse_yaml,
|
|
16
|
+
validate_config_version,
|
|
17
|
+
)
|
|
18
|
+
from weevr.config.resolver import (
|
|
19
|
+
build_param_context,
|
|
20
|
+
resolve_declared_params,
|
|
21
|
+
resolve_references,
|
|
22
|
+
resolve_variables,
|
|
23
|
+
validate_params,
|
|
24
|
+
)
|
|
25
|
+
from weevr.config.validation import validate_schema
|
|
26
|
+
from weevr.errors import ConfigError, ModelValidationError
|
|
27
|
+
from weevr.model import Loom, Thread, Weave
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _derive_config_name(path: ConfigLocation) -> str:
|
|
31
|
+
"""Derive the component name from a config location.
|
|
32
|
+
|
|
33
|
+
Returns the filename stem — the filename without the typed extension.
|
|
34
|
+
For example, ``dim_customer.thread`` returns ``'dim_customer'``.
|
|
35
|
+
"""
|
|
36
|
+
return path.stem
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def load_config(
|
|
40
|
+
path: str | Path | ConfigLocation,
|
|
41
|
+
runtime_params: dict[str, Any] | None = None,
|
|
42
|
+
project_root: Path | ConfigLocation | None = None,
|
|
43
|
+
) -> Thread | Weave | Loom | dict[str, Any]:
|
|
44
|
+
"""Load and validate a weevr configuration file.
|
|
45
|
+
|
|
46
|
+
This function orchestrates the full config loading pipeline:
|
|
47
|
+
1. Parse YAML file
|
|
48
|
+
2. Extract and validate config_version
|
|
49
|
+
3. Detect config type (extension-based for components, content-based for params)
|
|
50
|
+
4. Validate schema with Pydantic
|
|
51
|
+
5. Build parameter context (runtime > defaults)
|
|
52
|
+
6. Resolve variable references (${var} and ${var:-default})
|
|
53
|
+
7. Resolve references to child configs (threads, weaves)
|
|
54
|
+
8. Apply inheritance cascade (loom -> weave -> thread)
|
|
55
|
+
9. Validate name against filename stem
|
|
56
|
+
10. Hydrate into typed domain model (thread, weave, loom only)
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
path: Path or :class:`ConfigLocation` for the config file (thread,
|
|
60
|
+
weave, or loom). Local paths are wrapped automatically; remote
|
|
61
|
+
URIs must be supplied as a :class:`RemoteConfigLocation` so the
|
|
62
|
+
caller can attach a SparkSession.
|
|
63
|
+
runtime_params: Optional runtime parameter overrides.
|
|
64
|
+
project_root: The ``.weevr`` project directory. Required for configs
|
|
65
|
+
that reference other files. Accepts a :class:`ConfigLocation` or
|
|
66
|
+
a bare :class:`pathlib.Path`.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
A frozen, typed domain model instance (Thread, Weave, or Loom) for
|
|
70
|
+
thread/weave/loom config types. Returns a plain dict for params configs.
|
|
71
|
+
|
|
72
|
+
Raises:
|
|
73
|
+
ConfigParseError: YAML syntax errors, file not found
|
|
74
|
+
ConfigVersionError: Unsupported config version
|
|
75
|
+
ConfigSchemaError: Schema validation failures
|
|
76
|
+
ConfigError: Extension or name validation failures
|
|
77
|
+
VariableResolutionError: Unresolved variables without defaults
|
|
78
|
+
ReferenceResolutionError: Missing referenced files, circular dependencies
|
|
79
|
+
ModelValidationError: Semantic validation failures during model hydration
|
|
80
|
+
"""
|
|
81
|
+
file_path = make_location(path)
|
|
82
|
+
|
|
83
|
+
# Step 1: Parse YAML
|
|
84
|
+
raw = parse_yaml(file_path)
|
|
85
|
+
|
|
86
|
+
# Step 2: Extract and validate version
|
|
87
|
+
version = extract_config_version(raw)
|
|
88
|
+
|
|
89
|
+
# Step 3: Detect config type — extension-based for typed files, content-based for .yaml
|
|
90
|
+
ext_type = detect_config_type_from_extension(file_path)
|
|
91
|
+
config_type = ext_type if ext_type is not None else detect_config_type(raw)
|
|
92
|
+
|
|
93
|
+
# Step 4: Validate schema
|
|
94
|
+
validate_config_version(version, config_type)
|
|
95
|
+
validated = validate_schema(raw, config_type)
|
|
96
|
+
config_dict = validated.model_dump(exclude_unset=True)
|
|
97
|
+
|
|
98
|
+
# Step 5: Build parameter context (no more param_file)
|
|
99
|
+
declared_param_specs = config_dict.get("params")
|
|
100
|
+
resolved_declared_params = resolve_declared_params(
|
|
101
|
+
declared_param_specs,
|
|
102
|
+
runtime_params,
|
|
103
|
+
file_path=str(file_path),
|
|
104
|
+
)
|
|
105
|
+
# Type validation only — required-missing already raised above.
|
|
106
|
+
validate_params(declared_param_specs, resolved_declared_params)
|
|
107
|
+
context = build_param_context(
|
|
108
|
+
runtime_params,
|
|
109
|
+
config_dict.get("defaults"),
|
|
110
|
+
entry_params=resolved_declared_params or None,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# Step 6: Resolve variables
|
|
114
|
+
resolved = resolve_variables(config_dict, context)
|
|
115
|
+
|
|
116
|
+
# Step 7: Resolve references to child configs
|
|
117
|
+
if project_root is None:
|
|
118
|
+
effective_root: ConfigLocation = file_path.parent
|
|
119
|
+
elif isinstance(project_root, ConfigLocation):
|
|
120
|
+
effective_root = project_root
|
|
121
|
+
else:
|
|
122
|
+
effective_root = LocalConfigLocation(Path(project_root))
|
|
123
|
+
resolved_with_refs = resolve_references(
|
|
124
|
+
resolved,
|
|
125
|
+
config_type,
|
|
126
|
+
effective_root,
|
|
127
|
+
runtime_params,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
# Step 8: Apply inheritance for child configs
|
|
131
|
+
if config_type == "loom" and "_resolved_weaves" in resolved_with_refs:
|
|
132
|
+
loom_defaults = resolved_with_refs.get("defaults")
|
|
133
|
+
loom_audit_templates = resolved_with_refs.get("audit_templates")
|
|
134
|
+
loom_connections = resolved_with_refs.get("connections")
|
|
135
|
+
for weave in resolved_with_refs["_resolved_weaves"]:
|
|
136
|
+
if "_resolved_threads" in weave:
|
|
137
|
+
weave_defaults = weave.get("defaults")
|
|
138
|
+
weave_audit_templates = weave.get("audit_templates")
|
|
139
|
+
weave_connections = weave.get("connections")
|
|
140
|
+
for i, thread in enumerate(weave["_resolved_threads"]):
|
|
141
|
+
merged = apply_inheritance(
|
|
142
|
+
loom_defaults,
|
|
143
|
+
weave_defaults,
|
|
144
|
+
thread,
|
|
145
|
+
loom_audit_templates=loom_audit_templates,
|
|
146
|
+
weave_audit_templates=weave_audit_templates,
|
|
147
|
+
loom_connections=loom_connections,
|
|
148
|
+
weave_connections=weave_connections,
|
|
149
|
+
)
|
|
150
|
+
weave["_resolved_threads"][i] = merged
|
|
151
|
+
|
|
152
|
+
elif config_type == "weave" and "_resolved_threads" in resolved_with_refs:
|
|
153
|
+
weave_defaults = resolved_with_refs.get("defaults")
|
|
154
|
+
weave_audit_templates = resolved_with_refs.get("audit_templates")
|
|
155
|
+
weave_connections = resolved_with_refs.get("connections")
|
|
156
|
+
for i, thread in enumerate(resolved_with_refs["_resolved_threads"]):
|
|
157
|
+
merged = apply_inheritance(
|
|
158
|
+
None,
|
|
159
|
+
weave_defaults,
|
|
160
|
+
thread,
|
|
161
|
+
weave_audit_templates=weave_audit_templates,
|
|
162
|
+
weave_connections=weave_connections,
|
|
163
|
+
)
|
|
164
|
+
resolved_with_refs["_resolved_threads"][i] = merged
|
|
165
|
+
|
|
166
|
+
# Step 8b: Expand foreach macros in thread steps
|
|
167
|
+
if config_type == "thread" and isinstance(resolved_with_refs.get("steps"), list):
|
|
168
|
+
resolved_with_refs["steps"] = expand_foreach(resolved_with_refs["steps"])
|
|
169
|
+
|
|
170
|
+
# Step 9: Name validation and injection
|
|
171
|
+
if config_type in ("thread", "weave", "loom") and ext_type is not None:
|
|
172
|
+
stem = _derive_config_name(file_path)
|
|
173
|
+
declared_name = resolved_with_refs.get("name", "")
|
|
174
|
+
if declared_name and declared_name != stem:
|
|
175
|
+
raise ConfigError(
|
|
176
|
+
f"Declared name '{declared_name}' does not match filename stem '{stem}'",
|
|
177
|
+
file_path=str(file_path),
|
|
178
|
+
)
|
|
179
|
+
if not declared_name:
|
|
180
|
+
resolved_with_refs["name"] = stem
|
|
181
|
+
|
|
182
|
+
# Compute qualified key for standalone files
|
|
183
|
+
if project_root is not None and ext_type is not None:
|
|
184
|
+
if file_path.is_relative_to(effective_root):
|
|
185
|
+
# Strip the project_root prefix to produce a relative key.
|
|
186
|
+
# For local locations this matches the previous Path.resolve()
|
|
187
|
+
# / Path.relative_to behavior; for remote locations it is a
|
|
188
|
+
# normalized URI prefix strip.
|
|
189
|
+
if isinstance(file_path, LocalConfigLocation) and isinstance(
|
|
190
|
+
effective_root, LocalConfigLocation
|
|
191
|
+
):
|
|
192
|
+
rel = file_path.path.resolve().relative_to(effective_root.path.resolve())
|
|
193
|
+
resolved_with_refs["qualified_key"] = str(rel)
|
|
194
|
+
else:
|
|
195
|
+
file_str = str(file_path)
|
|
196
|
+
root_str = str(effective_root).rstrip("/")
|
|
197
|
+
resolved_with_refs["qualified_key"] = (
|
|
198
|
+
file_str[len(root_str) + 1 :]
|
|
199
|
+
if file_str.startswith(root_str + "/")
|
|
200
|
+
else file_str
|
|
201
|
+
)
|
|
202
|
+
else:
|
|
203
|
+
resolved_with_refs["qualified_key"] = str(file_path)
|
|
204
|
+
elif not resolved_with_refs.get("name"):
|
|
205
|
+
resolved_with_refs["name"] = file_path.stem
|
|
206
|
+
|
|
207
|
+
# Step 10: Hydrate into typed domain model
|
|
208
|
+
model_map: dict[str, type[Thread | Weave | Loom]] = {
|
|
209
|
+
"thread": Thread,
|
|
210
|
+
"weave": Weave,
|
|
211
|
+
"loom": Loom,
|
|
212
|
+
}
|
|
213
|
+
if config_type in model_map:
|
|
214
|
+
try:
|
|
215
|
+
return model_map[config_type].model_validate(resolved_with_refs)
|
|
216
|
+
except ValidationError as exc:
|
|
217
|
+
raise ModelValidationError(
|
|
218
|
+
f"Model hydration failed for {config_type}: {exc}",
|
|
219
|
+
cause=exc,
|
|
220
|
+
file_path=str(path),
|
|
221
|
+
) from exc
|
|
222
|
+
|
|
223
|
+
# params config type returns a plain dict
|
|
224
|
+
return resolved_with_refs
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# Federated namespace: weevr.config splits across two wheels — pure
|
|
228
|
+
# parser/resolver/validation/inheritance modules in weevr-core (which
|
|
229
|
+
# ships this __init__.py) and Spark-bound config helpers (fabric.py,
|
|
230
|
+
# paths.py) contributed by the weevr engine wheel. In editable workspace
|
|
231
|
+
# installs each wheel's src/ is a separate sys.path entry, so we extend
|
|
232
|
+
# __path__ to pull engine-side contributions into the import-time view.
|
|
233
|
+
# In a real install both wheels' files merge into a single
|
|
234
|
+
# site-packages/weevr/config/ directory and the loop is a no-op. The
|
|
235
|
+
# basename(p) == "src" gate scopes the scan to src-layout package roots.
|
|
236
|
+
def _extend_namespace_path() -> None:
|
|
237
|
+
"""Append sibling weevr.config/ directories on sys.path to ``__path__``."""
|
|
238
|
+
import os.path as osp
|
|
239
|
+
import sys
|
|
240
|
+
|
|
241
|
+
components = __name__.split(".")
|
|
242
|
+
src_entries_seen = 0
|
|
243
|
+
candidates_added = 0
|
|
244
|
+
for entry in sys.path:
|
|
245
|
+
if not entry or osp.basename(entry) != "src":
|
|
246
|
+
continue
|
|
247
|
+
src_entries_seen += 1
|
|
248
|
+
candidate = osp.join(entry, *components)
|
|
249
|
+
if osp.isdir(candidate) and candidate not in __path__:
|
|
250
|
+
__path__.append(candidate)
|
|
251
|
+
candidates_added += 1
|
|
252
|
+
if src_entries_seen > 0 and candidates_added == 0:
|
|
253
|
+
import warnings
|
|
254
|
+
|
|
255
|
+
warnings.warn(
|
|
256
|
+
f"weevr federated namespace {__name__!r}: no sibling "
|
|
257
|
+
"contributor found on sys.path. If this is an editable "
|
|
258
|
+
"workspace install, run `uv sync --dev` from the repo root "
|
|
259
|
+
"to install both wheels.",
|
|
260
|
+
stacklevel=2,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
_extend_namespace_path()
|
|
265
|
+
del _extend_namespace_path
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""Pure-Python helpers for resolving and merging audit column templates.
|
|
2
|
+
|
|
3
|
+
This is the canonical home for the audit-template merge primitives used by
|
|
4
|
+
``weevr.config.inheritance``. The historical home in ``weevr.operations.audit``
|
|
5
|
+
re-exports these symbols for back-compat with external callers; core modules
|
|
6
|
+
(notably ``weevr.config.inheritance``) MUST import from here directly so the
|
|
7
|
+
config layer stays Spark-free.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import fnmatch
|
|
13
|
+
import logging
|
|
14
|
+
import re
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
from weevr.errors.exceptions import ConfigError
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"BUILTIN_AUDIT_PRESETS",
|
|
23
|
+
"AuditContext",
|
|
24
|
+
"CONTEXT_VAR_PATTERN",
|
|
25
|
+
"resolve_audit_columns",
|
|
26
|
+
"apply_audit_exclusions",
|
|
27
|
+
"resolve_template_columns",
|
|
28
|
+
"merge_audit_columns_with_templates",
|
|
29
|
+
"resolve_context_variables",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# Built-in audit presets
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
#: Built-in audit column presets keyed by name.
|
|
37
|
+
#:
|
|
38
|
+
#: ``fabric`` — nine columns aligned with Microsoft Fabric pipeline parameters.
|
|
39
|
+
#: ``minimal`` — three lightweight columns for simple run tracking.
|
|
40
|
+
BUILTIN_AUDIT_PRESETS: dict[str, dict[str, str]] = {
|
|
41
|
+
"fabric": {
|
|
42
|
+
"_batch_id": "${param.batch_id}",
|
|
43
|
+
"_batch_version": "${param.batch_version}",
|
|
44
|
+
"_batch_source": "${param.batch_source}",
|
|
45
|
+
"_batch_process_ts": "current_timestamp()",
|
|
46
|
+
"_pipeline_id": "${param.pipeline_id}",
|
|
47
|
+
"_pipeline_name": "${param.pipeline_name}",
|
|
48
|
+
"_workspace_id": "${param.workspace_id}",
|
|
49
|
+
"_spark_app_id": "spark_context().applicationId",
|
|
50
|
+
"_task_ts": "current_timestamp()",
|
|
51
|
+
},
|
|
52
|
+
"minimal": {
|
|
53
|
+
"_weevr_loaded_at": "current_timestamp()",
|
|
54
|
+
"_weevr_run_id": "${param.run_id}",
|
|
55
|
+
"_weevr_thread": "${thread.name}",
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class AuditContext:
|
|
62
|
+
"""Execution context for resolving audit column variables.
|
|
63
|
+
|
|
64
|
+
Attributes:
|
|
65
|
+
thread_name: Thread name (e.g. ``stg_orders``).
|
|
66
|
+
thread_qualified_key: Fully qualified key (e.g. ``staging.stg_orders``).
|
|
67
|
+
thread_source: Primary source alias (first declared source).
|
|
68
|
+
thread_sources_json: JSON array of all sources with type metadata.
|
|
69
|
+
weave_name: Weave name (e.g. ``staging``).
|
|
70
|
+
loom_name: Loom name (e.g. ``my-project``).
|
|
71
|
+
run_timestamp: ISO 8601 UTC timestamp of execution start.
|
|
72
|
+
run_id: UUID4 identifier unique per execution.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
thread_name: str
|
|
76
|
+
thread_qualified_key: str
|
|
77
|
+
thread_source: str | None
|
|
78
|
+
thread_sources_json: str
|
|
79
|
+
weave_name: str
|
|
80
|
+
loom_name: str
|
|
81
|
+
run_timestamp: str = ""
|
|
82
|
+
run_id: str = ""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# Pattern matching ${namespace.property} context variables.
|
|
86
|
+
CONTEXT_VAR_PATTERN = re.compile(r"\$\{(thread|weave|loom|run)\.([a-z_]+)\}")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def resolve_audit_columns(
|
|
90
|
+
loom_audit: dict[str, str] | None,
|
|
91
|
+
weave_audit: dict[str, str] | None,
|
|
92
|
+
thread_audit: dict[str, str] | None,
|
|
93
|
+
) -> dict[str, str]:
|
|
94
|
+
"""Merge audit columns additively: loom -> weave -> thread.
|
|
95
|
+
|
|
96
|
+
Lower levels extend the column set. Same-named columns at a lower
|
|
97
|
+
level override the expression from a higher level.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
loom_audit: Audit columns from loom defaults.
|
|
101
|
+
weave_audit: Audit columns from weave defaults.
|
|
102
|
+
thread_audit: Audit columns from thread target.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
Merged audit column dict. Empty dict if none defined.
|
|
106
|
+
"""
|
|
107
|
+
merged: dict[str, str] = {}
|
|
108
|
+
if loom_audit:
|
|
109
|
+
merged.update(loom_audit)
|
|
110
|
+
if weave_audit:
|
|
111
|
+
merged.update(weave_audit)
|
|
112
|
+
if thread_audit:
|
|
113
|
+
merged.update(thread_audit)
|
|
114
|
+
return merged
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def apply_audit_exclusions(
|
|
118
|
+
columns: dict[str, str],
|
|
119
|
+
exclude_patterns: list[str] | None,
|
|
120
|
+
) -> dict[str, str]:
|
|
121
|
+
"""Remove audit columns whose names match any of the given glob patterns.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
columns: Resolved audit column definitions as ``{name: expression}``.
|
|
125
|
+
exclude_patterns: List of glob patterns (``fnmatch`` syntax). ``None``
|
|
126
|
+
or an empty list is a no-op.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
Copy of ``columns`` with matching entries removed.
|
|
130
|
+
"""
|
|
131
|
+
if not exclude_patterns:
|
|
132
|
+
return columns
|
|
133
|
+
|
|
134
|
+
result: dict[str, str] = {}
|
|
135
|
+
for name, expression in columns.items():
|
|
136
|
+
matched = False
|
|
137
|
+
for pattern in exclude_patterns:
|
|
138
|
+
if fnmatch.fnmatch(name, pattern):
|
|
139
|
+
logger.debug("Audit column %r excluded by pattern %r", name, pattern)
|
|
140
|
+
matched = True
|
|
141
|
+
break
|
|
142
|
+
if not matched:
|
|
143
|
+
result[name] = expression
|
|
144
|
+
|
|
145
|
+
return result
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def resolve_template_columns(
|
|
149
|
+
template_names: list[str],
|
|
150
|
+
user_templates: dict[str, dict[str, str]] | None = None,
|
|
151
|
+
) -> dict[str, str]:
|
|
152
|
+
"""Resolve template names to merged column definitions.
|
|
153
|
+
|
|
154
|
+
Looks up each name in user-defined templates first, then built-in
|
|
155
|
+
presets. Logs a warning when a user-defined template shadows a
|
|
156
|
+
built-in preset. Raises ConfigError if a name is not found.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
template_names: Ordered list of template names to resolve.
|
|
160
|
+
user_templates: User-defined templates keyed by name.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
Merged column dict. Later names override earlier on collision.
|
|
164
|
+
|
|
165
|
+
Raises:
|
|
166
|
+
ConfigError: If a template name is not found in user-defined
|
|
167
|
+
or built-in presets.
|
|
168
|
+
"""
|
|
169
|
+
resolved_user = user_templates or {}
|
|
170
|
+
merged: dict[str, str] = {}
|
|
171
|
+
|
|
172
|
+
for name in template_names:
|
|
173
|
+
if name in resolved_user:
|
|
174
|
+
if name in BUILTIN_AUDIT_PRESETS:
|
|
175
|
+
logger.warning(
|
|
176
|
+
"User-defined template %r shadows built-in preset with the same name",
|
|
177
|
+
name,
|
|
178
|
+
)
|
|
179
|
+
merged.update(resolved_user[name])
|
|
180
|
+
elif name in BUILTIN_AUDIT_PRESETS:
|
|
181
|
+
merged.update(BUILTIN_AUDIT_PRESETS[name])
|
|
182
|
+
else:
|
|
183
|
+
available = sorted(set(resolved_user) | set(BUILTIN_AUDIT_PRESETS))
|
|
184
|
+
raise ConfigError(
|
|
185
|
+
f"Audit template {name!r} not found. Available templates: {', '.join(available)}"
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
return merged
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def merge_audit_columns_with_templates(
|
|
192
|
+
*,
|
|
193
|
+
loom_templates: dict[str, dict[str, str]] | None = None,
|
|
194
|
+
loom_template_refs: list[str] | None = None,
|
|
195
|
+
loom_inline: dict[str, str] | None = None,
|
|
196
|
+
weave_templates: dict[str, dict[str, str]] | None = None,
|
|
197
|
+
weave_template_refs: list[str] | None = None,
|
|
198
|
+
weave_inline: dict[str, str] | None = None,
|
|
199
|
+
thread_templates: dict[str, dict[str, str]] | None = None,
|
|
200
|
+
thread_template_refs: list[str] | None = None,
|
|
201
|
+
thread_inline: dict[str, str] | None = None,
|
|
202
|
+
thread_inherit: bool = True,
|
|
203
|
+
thread_exclude: list[str] | None = None,
|
|
204
|
+
) -> dict[str, str]:
|
|
205
|
+
"""Merge audit columns from templates, inline definitions, and inheritance.
|
|
206
|
+
|
|
207
|
+
Resolution order:
|
|
208
|
+
1. Collect user-defined templates from all levels.
|
|
209
|
+
2. If thread_inherit is False, skip loom and weave template refs and inline.
|
|
210
|
+
3. Resolve template refs at each level via resolve_template_columns().
|
|
211
|
+
4. Merge: loom template cols → loom inline → weave template cols
|
|
212
|
+
→ weave inline → thread template cols → thread inline.
|
|
213
|
+
5. Apply apply_audit_exclusions() with thread_exclude.
|
|
214
|
+
|
|
215
|
+
User-defined template definitions from all levels are always available for
|
|
216
|
+
name resolution, even when thread_inherit is False. Inheritance only controls
|
|
217
|
+
whether loom and weave template refs and inline columns contribute to the
|
|
218
|
+
merged output.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
loom_templates: User-defined templates declared at the loom level.
|
|
222
|
+
loom_template_refs: Template names to resolve at the loom level.
|
|
223
|
+
loom_inline: Explicit audit columns defined inline at the loom level.
|
|
224
|
+
weave_templates: User-defined templates declared at the weave level.
|
|
225
|
+
weave_template_refs: Template names to resolve at the weave level.
|
|
226
|
+
weave_inline: Explicit audit columns defined inline at the weave level.
|
|
227
|
+
thread_templates: User-defined templates declared at the thread level.
|
|
228
|
+
thread_template_refs: Template names to resolve at the thread level.
|
|
229
|
+
thread_inline: Explicit audit columns defined inline at the thread level.
|
|
230
|
+
thread_inherit: When False, loom and weave template refs and inline
|
|
231
|
+
columns are excluded from the merged result. Defaults to True.
|
|
232
|
+
thread_exclude: Glob patterns applied after all merging. Matching
|
|
233
|
+
column names are removed from the final result.
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
Merged audit column dict. Empty dict if nothing is defined.
|
|
237
|
+
"""
|
|
238
|
+
# Merge all user-defined templates across levels so they are available for
|
|
239
|
+
# any level's template refs regardless of inheritance setting.
|
|
240
|
+
all_user_templates: dict[str, dict[str, str]] = {}
|
|
241
|
+
if loom_templates:
|
|
242
|
+
all_user_templates.update(loom_templates)
|
|
243
|
+
if weave_templates:
|
|
244
|
+
all_user_templates.update(weave_templates)
|
|
245
|
+
if thread_templates:
|
|
246
|
+
all_user_templates.update(thread_templates)
|
|
247
|
+
|
|
248
|
+
merged: dict[str, str] = {}
|
|
249
|
+
|
|
250
|
+
if thread_inherit:
|
|
251
|
+
if loom_template_refs:
|
|
252
|
+
merged.update(resolve_template_columns(loom_template_refs, all_user_templates))
|
|
253
|
+
if loom_inline:
|
|
254
|
+
merged.update(loom_inline)
|
|
255
|
+
if weave_template_refs:
|
|
256
|
+
merged.update(resolve_template_columns(weave_template_refs, all_user_templates))
|
|
257
|
+
if weave_inline:
|
|
258
|
+
merged.update(weave_inline)
|
|
259
|
+
|
|
260
|
+
if thread_template_refs:
|
|
261
|
+
merged.update(resolve_template_columns(thread_template_refs, all_user_templates))
|
|
262
|
+
if thread_inline:
|
|
263
|
+
merged.update(thread_inline)
|
|
264
|
+
|
|
265
|
+
return apply_audit_exclusions(merged, thread_exclude)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def resolve_context_variables(expression: str, context: AuditContext) -> str:
|
|
269
|
+
"""Substitute ${namespace.property} variables in an expression string.
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
expression: Spark SQL expression with context variable placeholders.
|
|
273
|
+
context: Audit context providing variable values.
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
Expression with context variables replaced by their values.
|
|
277
|
+
"""
|
|
278
|
+
lookup: dict[str, dict[str, str | None]] = {
|
|
279
|
+
"thread": {
|
|
280
|
+
"name": context.thread_name,
|
|
281
|
+
"qualified_key": context.thread_qualified_key,
|
|
282
|
+
"source": context.thread_source,
|
|
283
|
+
"sources": context.thread_sources_json,
|
|
284
|
+
},
|
|
285
|
+
"weave": {
|
|
286
|
+
"name": context.weave_name,
|
|
287
|
+
},
|
|
288
|
+
"loom": {
|
|
289
|
+
"name": context.loom_name,
|
|
290
|
+
},
|
|
291
|
+
"run": {
|
|
292
|
+
"timestamp": context.run_timestamp,
|
|
293
|
+
"id": context.run_id,
|
|
294
|
+
},
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
def _replace(match: re.Match[str]) -> str:
|
|
298
|
+
namespace = match.group(1)
|
|
299
|
+
prop = match.group(2)
|
|
300
|
+
ns_lookup = lookup.get(namespace, {})
|
|
301
|
+
if prop not in ns_lookup:
|
|
302
|
+
return match.group(0) # Unknown property — leave unresolved
|
|
303
|
+
value = ns_lookup[prop]
|
|
304
|
+
if value is None:
|
|
305
|
+
logger.warning(
|
|
306
|
+
"Audit column context variable ${%s.%s} resolved to None, "
|
|
307
|
+
"substituting empty string",
|
|
308
|
+
namespace,
|
|
309
|
+
prop,
|
|
310
|
+
)
|
|
311
|
+
return ""
|
|
312
|
+
return value
|
|
313
|
+
|
|
314
|
+
return CONTEXT_VAR_PATTERN.sub(_replace, expression)
|