composable-data-stack 0.4.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.
cli/overlay.py ADDED
@@ -0,0 +1,239 @@
1
+ # cli/overlay.py
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from .diagnostics import Diagnostic
8
+ from .loader import _is_within, load_yaml_file
9
+ from .validator import validate_loaded_profile
10
+
11
+
12
+ def _duplicate_module_ids(modules: list[dict[str, Any]]) -> set[str]:
13
+ seen: set[str] = set()
14
+ dupes: set[str] = set()
15
+ for m in modules:
16
+ if not isinstance(m, dict):
17
+ continue
18
+ mid = m.get("id")
19
+ if mid is None:
20
+ continue
21
+ if mid in seen:
22
+ dupes.add(mid)
23
+ seen.add(mid)
24
+ return dupes
25
+
26
+
27
+ def _merge_value(
28
+ base: Any,
29
+ overlay: Any,
30
+ base_source: str,
31
+ overlay_source: str,
32
+ path: str,
33
+ provenance: dict[str, str],
34
+ ) -> Any:
35
+ if isinstance(base, dict) and isinstance(overlay, dict):
36
+ result: dict[str, Any] = {}
37
+ for key, value in base.items():
38
+ child_path = f"{path}.{key}" if path else key
39
+ result[key] = value
40
+ provenance.setdefault(child_path, base_source)
41
+ for key, value in overlay.items():
42
+ child_path = f"{path}.{key}" if path else key
43
+ if key in result:
44
+ result[key] = _merge_value(
45
+ result[key], value, base_source, overlay_source, child_path, provenance
46
+ )
47
+ else:
48
+ result[key] = value
49
+ provenance[child_path] = overlay_source
50
+ return result
51
+
52
+ provenance[path] = overlay_source
53
+ return overlay
54
+
55
+
56
+ def _merge_modules(
57
+ base_modules: list[dict[str, Any]],
58
+ overlay_modules: list[dict[str, Any]],
59
+ base_source: str,
60
+ overlay_source: str,
61
+ provenance: dict[str, str],
62
+ ) -> list[dict[str, Any]]:
63
+ by_id: dict[str, dict[str, Any]] = {}
64
+ order: list[str] = []
65
+
66
+ for module in base_modules:
67
+ mid = module["id"]
68
+ by_id[mid] = module
69
+ order.append(mid)
70
+ provenance[f"spec.modules[{mid}]"] = base_source
71
+
72
+ for module in overlay_modules:
73
+ mid = module["id"]
74
+ if mid in by_id:
75
+ by_id[mid] = _merge_value(
76
+ by_id[mid], module, base_source, overlay_source, f"spec.modules[{mid}]", provenance
77
+ )
78
+ else:
79
+ by_id[mid] = module
80
+ order.append(mid)
81
+ provenance[f"spec.modules[{mid}]"] = overlay_source
82
+
83
+ return [by_id[mid] for mid in order]
84
+
85
+
86
+ def resolve_profile(
87
+ profile_path: str,
88
+ environment: str | None = None,
89
+ ) -> tuple[dict[str, Any] | None, dict[str, str], list[Diagnostic]]:
90
+ """
91
+ Loads and, if an environment is selected, merges the profile at
92
+ profile_path with profiles/<name>/environments/<environment>.yaml.
93
+
94
+ Returns (resolved_profile, provenance, diagnostics). provenance maps
95
+ dotted config paths (and "spec.modules[<id>]" for whole module entries)
96
+ to the source file responsible for that value. resolved_profile is
97
+ None if resolution or validation failed (see diagnostics).
98
+
99
+ environment=None (the default) reproduces the exact behavior of
100
+ load_yaml_file + validate_loaded_profile on the base profile alone,
101
+ standalone profiles are unaffected by this resolver existing.
102
+ """
103
+ profile_file = Path(profile_path)
104
+ base, diagnostics = load_yaml_file(profile_file)
105
+ if base is None:
106
+ return None, {}, diagnostics
107
+
108
+ if environment is None:
109
+ diagnostics += validate_loaded_profile(base, profile_file)
110
+ provenance = {}
111
+ return (base, provenance, diagnostics) if not any(
112
+ d.level == "error" for d in diagnostics
113
+ ) else (None, provenance, diagnostics)
114
+
115
+ profile_dir = profile_file.parent
116
+ environments_dir = profile_dir / "environments"
117
+ overlay_file = environments_dir / f"{environment}.yaml"
118
+
119
+ if not _is_within(overlay_file, environments_dir):
120
+ diagnostics.append(
121
+ Diagnostic(
122
+ level="error",
123
+ code="E090",
124
+ message=f'Environment "{environment}" resolves outside the profile\'s environments/ directory.',
125
+ path="environment",
126
+ )
127
+ )
128
+ return None, {}, diagnostics
129
+
130
+ if not overlay_file.is_file():
131
+ diagnostics.append(
132
+ Diagnostic(
133
+ level="error",
134
+ code="E091",
135
+ message=f'Unknown environment "{environment}": {overlay_file} does not exist.',
136
+ path="environment",
137
+ )
138
+ )
139
+ return None, {}, diagnostics
140
+
141
+ overlay, overlay_diags = load_yaml_file(overlay_file)
142
+ diagnostics += overlay_diags
143
+ if overlay is None:
144
+ return None, {}, diagnostics
145
+
146
+ base_source = str(profile_file)
147
+ overlay_source = str(overlay_file)
148
+
149
+ base_spec = base.get("spec", {})
150
+ base_modules = base_spec.get("modules", []) if isinstance(base_spec, dict) else []
151
+ overlay_spec = overlay.get("spec", {})
152
+ overlay_modules = overlay_spec.get("modules", []) if isinstance(overlay_spec, dict) else []
153
+
154
+ for label, modules in (("base profile", base_modules), (f"overlay {overlay_source}", overlay_modules)):
155
+ if not isinstance(modules, list):
156
+ diagnostics.append(
157
+ Diagnostic(
158
+ level="error",
159
+ code="E093",
160
+ message=f"spec.modules in {label} must be a list, got {type(modules).__name__}.",
161
+ path="spec.modules",
162
+ )
163
+ )
164
+ continue
165
+
166
+ non_dict_indices = [i for i, m in enumerate(modules) if not isinstance(m, dict)]
167
+ if non_dict_indices:
168
+ diagnostics.append(
169
+ Diagnostic(
170
+ level="error",
171
+ code="E093",
172
+ message=(
173
+ f"Module entr{'y' if len(non_dict_indices) == 1 else 'ies'} in {label} "
174
+ f"must be a mapping, not a scalar/list, at index {non_dict_indices}."
175
+ ),
176
+ path="spec.modules",
177
+ )
178
+ )
179
+
180
+ if any(d.level == "error" for d in diagnostics):
181
+ return None, {}, diagnostics
182
+
183
+ for label, modules in (("base profile", base_modules), (f"overlay {overlay_source}", overlay_modules)):
184
+ missing_id = [i for i, m in enumerate(modules) if not m.get("id")]
185
+ if missing_id:
186
+ diagnostics.append(
187
+ Diagnostic(
188
+ level="error",
189
+ code="E093",
190
+ message=f"Module entr{'y' if len(missing_id) == 1 else 'ies'} in {label} missing required 'id' at index {missing_id}.",
191
+ path="spec.modules",
192
+ )
193
+ )
194
+ if any(d.level == "error" for d in diagnostics):
195
+ return None, {}, diagnostics
196
+
197
+ for label, modules in (("base profile", base_modules), (f"overlay {overlay_source}", overlay_modules)):
198
+ dupes = _duplicate_module_ids(modules)
199
+ if dupes:
200
+ diagnostics.append(
201
+ Diagnostic(
202
+ level="error",
203
+ code="E093",
204
+ message=f"Duplicate module id(s) in {label}: {sorted(dupes)}.",
205
+ path="spec.modules",
206
+ )
207
+ )
208
+ if any(d.level == "error" for d in diagnostics):
209
+ return None, {}, diagnostics
210
+
211
+ provenance: dict[str, str] = {}
212
+
213
+ base_without_modules = dict(base)
214
+ base_spec_val = base.get("spec", {})
215
+ base_spec_without_modules = (
216
+ {k: v for k, v in base_spec_val.items() if k != "modules"}
217
+ if isinstance(base_spec_val, dict)
218
+ else {}
219
+ )
220
+ base_without_modules["spec"] = base_spec_without_modules
221
+
222
+ overlay_without_modules = dict(overlay)
223
+ if "spec" in overlay and isinstance(overlay.get("spec"), dict):
224
+ overlay_spec_without_modules = {k: v for k, v in overlay["spec"].items() if k != "modules"}
225
+ overlay_without_modules["spec"] = overlay_spec_without_modules
226
+
227
+ merged = _merge_value(
228
+ base_without_modules, overlay_without_modules, base_source, overlay_source, "", provenance
229
+ )
230
+ merged.setdefault("spec", {})
231
+ merged["spec"]["modules"] = _merge_modules(
232
+ base_modules, overlay_modules, base_source, overlay_source, provenance
233
+ )
234
+
235
+ diagnostics += validate_loaded_profile(merged, profile_file)
236
+ if any(d.level == "error" for d in diagnostics):
237
+ return None, provenance, diagnostics
238
+
239
+ return merged, provenance, diagnostics