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.
Files changed (54) hide show
  1. weevr/config/__init__.py +265 -0
  2. weevr/config/audit_templates.py +314 -0
  3. weevr/config/duration.py +5 -0
  4. weevr/config/inheritance.py +402 -0
  5. weevr/config/locations.py +492 -0
  6. weevr/config/macros.py +71 -0
  7. weevr/config/parser.py +213 -0
  8. weevr/config/resolver.py +750 -0
  9. weevr/config/validation.py +312 -0
  10. weevr/config/warp.py +223 -0
  11. weevr/duration.py +48 -0
  12. weevr/engine/formatting.py +23 -0
  13. weevr/engine/planner.py +513 -0
  14. weevr/errors/__init__.py +43 -0
  15. weevr/errors/exceptions.py +422 -0
  16. weevr/model/__init__.py +168 -0
  17. weevr/model/audit.py +29 -0
  18. weevr/model/base.py +13 -0
  19. weevr/model/column_set.py +343 -0
  20. weevr/model/connection.py +29 -0
  21. weevr/model/dimension.py +377 -0
  22. weevr/model/execution.py +38 -0
  23. weevr/model/export.py +143 -0
  24. weevr/model/fact.py +67 -0
  25. weevr/model/failure.py +26 -0
  26. weevr/model/hooks.py +227 -0
  27. weevr/model/keys.py +98 -0
  28. weevr/model/load.py +209 -0
  29. weevr/model/lookup.py +95 -0
  30. weevr/model/loom.py +134 -0
  31. weevr/model/naming.py +78 -0
  32. weevr/model/params.py +44 -0
  33. weevr/model/pipeline.py +1085 -0
  34. weevr/model/seed.py +44 -0
  35. weevr/model/source.py +184 -0
  36. weevr/model/sub_pipeline.py +42 -0
  37. weevr/model/target.py +217 -0
  38. weevr/model/thread.py +242 -0
  39. weevr/model/types.py +12 -0
  40. weevr/model/validation.py +132 -0
  41. weevr/model/variable.py +27 -0
  42. weevr/model/warp.py +182 -0
  43. weevr/model/weave.py +187 -0
  44. weevr/model/write.py +73 -0
  45. weevr/reserved_words.py +442 -0
  46. weevr/result.py +637 -0
  47. weevr/telemetry/events.py +79 -0
  48. weevr/telemetry/results.py +181 -0
  49. weevr/telemetry/span.py +65 -0
  50. weevr/telemetry/trace.py +66 -0
  51. weevr_core-1.18.0.dist-info/METADATA +39 -0
  52. weevr_core-1.18.0.dist-info/RECORD +54 -0
  53. weevr_core-1.18.0.dist-info/WHEEL +4 -0
  54. weevr_core-1.18.0.dist-info/licenses/LICENSE +201 -0
@@ -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)
@@ -0,0 +1,5 @@
1
+ """Duration string parser — re-exported from weevr.duration for backward compatibility."""
2
+
3
+ from weevr.duration import parse_duration
4
+
5
+ __all__ = ["parse_duration"]