odibi 2.5.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.
- odibi/__init__.py +32 -0
- odibi/__main__.py +8 -0
- odibi/catalog.py +3011 -0
- odibi/cli/__init__.py +11 -0
- odibi/cli/__main__.py +6 -0
- odibi/cli/catalog.py +553 -0
- odibi/cli/deploy.py +69 -0
- odibi/cli/doctor.py +161 -0
- odibi/cli/export.py +66 -0
- odibi/cli/graph.py +150 -0
- odibi/cli/init_pipeline.py +242 -0
- odibi/cli/lineage.py +259 -0
- odibi/cli/main.py +215 -0
- odibi/cli/run.py +98 -0
- odibi/cli/schema.py +208 -0
- odibi/cli/secrets.py +232 -0
- odibi/cli/story.py +379 -0
- odibi/cli/system.py +132 -0
- odibi/cli/test.py +286 -0
- odibi/cli/ui.py +31 -0
- odibi/cli/validate.py +39 -0
- odibi/config.py +3541 -0
- odibi/connections/__init__.py +9 -0
- odibi/connections/azure_adls.py +499 -0
- odibi/connections/azure_sql.py +709 -0
- odibi/connections/base.py +28 -0
- odibi/connections/factory.py +322 -0
- odibi/connections/http.py +78 -0
- odibi/connections/local.py +119 -0
- odibi/connections/local_dbfs.py +61 -0
- odibi/constants.py +17 -0
- odibi/context.py +528 -0
- odibi/diagnostics/__init__.py +12 -0
- odibi/diagnostics/delta.py +520 -0
- odibi/diagnostics/diff.py +169 -0
- odibi/diagnostics/manager.py +171 -0
- odibi/engine/__init__.py +20 -0
- odibi/engine/base.py +334 -0
- odibi/engine/pandas_engine.py +2178 -0
- odibi/engine/polars_engine.py +1114 -0
- odibi/engine/registry.py +54 -0
- odibi/engine/spark_engine.py +2362 -0
- odibi/enums.py +7 -0
- odibi/exceptions.py +297 -0
- odibi/graph.py +426 -0
- odibi/introspect.py +1214 -0
- odibi/lineage.py +511 -0
- odibi/node.py +3341 -0
- odibi/orchestration/__init__.py +0 -0
- odibi/orchestration/airflow.py +90 -0
- odibi/orchestration/dagster.py +77 -0
- odibi/patterns/__init__.py +24 -0
- odibi/patterns/aggregation.py +599 -0
- odibi/patterns/base.py +94 -0
- odibi/patterns/date_dimension.py +423 -0
- odibi/patterns/dimension.py +696 -0
- odibi/patterns/fact.py +748 -0
- odibi/patterns/merge.py +128 -0
- odibi/patterns/scd2.py +148 -0
- odibi/pipeline.py +2382 -0
- odibi/plugins.py +80 -0
- odibi/project.py +581 -0
- odibi/references.py +151 -0
- odibi/registry.py +246 -0
- odibi/semantics/__init__.py +71 -0
- odibi/semantics/materialize.py +392 -0
- odibi/semantics/metrics.py +361 -0
- odibi/semantics/query.py +743 -0
- odibi/semantics/runner.py +430 -0
- odibi/semantics/story.py +507 -0
- odibi/semantics/views.py +432 -0
- odibi/state/__init__.py +1203 -0
- odibi/story/__init__.py +55 -0
- odibi/story/doc_story.py +554 -0
- odibi/story/generator.py +1431 -0
- odibi/story/lineage.py +1043 -0
- odibi/story/lineage_utils.py +324 -0
- odibi/story/metadata.py +608 -0
- odibi/story/renderers.py +453 -0
- odibi/story/templates/run_story.html +2520 -0
- odibi/story/themes.py +216 -0
- odibi/testing/__init__.py +13 -0
- odibi/testing/assertions.py +75 -0
- odibi/testing/fixtures.py +85 -0
- odibi/testing/source_pool.py +277 -0
- odibi/transformers/__init__.py +122 -0
- odibi/transformers/advanced.py +1472 -0
- odibi/transformers/delete_detection.py +610 -0
- odibi/transformers/manufacturing.py +1029 -0
- odibi/transformers/merge_transformer.py +778 -0
- odibi/transformers/relational.py +675 -0
- odibi/transformers/scd.py +579 -0
- odibi/transformers/sql_core.py +1356 -0
- odibi/transformers/validation.py +165 -0
- odibi/ui/__init__.py +0 -0
- odibi/ui/app.py +195 -0
- odibi/utils/__init__.py +66 -0
- odibi/utils/alerting.py +667 -0
- odibi/utils/config_loader.py +343 -0
- odibi/utils/console.py +231 -0
- odibi/utils/content_hash.py +202 -0
- odibi/utils/duration.py +43 -0
- odibi/utils/encoding.py +102 -0
- odibi/utils/extensions.py +28 -0
- odibi/utils/hashing.py +61 -0
- odibi/utils/logging.py +203 -0
- odibi/utils/logging_context.py +740 -0
- odibi/utils/progress.py +429 -0
- odibi/utils/setup_helpers.py +302 -0
- odibi/utils/telemetry.py +140 -0
- odibi/validation/__init__.py +62 -0
- odibi/validation/engine.py +765 -0
- odibi/validation/explanation_linter.py +155 -0
- odibi/validation/fk.py +547 -0
- odibi/validation/gate.py +252 -0
- odibi/validation/quarantine.py +605 -0
- odibi/writers/__init__.py +15 -0
- odibi/writers/sql_server_writer.py +2081 -0
- odibi-2.5.0.dist-info/METADATA +255 -0
- odibi-2.5.0.dist-info/RECORD +124 -0
- odibi-2.5.0.dist-info/WHEEL +5 -0
- odibi-2.5.0.dist-info/entry_points.txt +2 -0
- odibi-2.5.0.dist-info/licenses/LICENSE +190 -0
- odibi-2.5.0.dist-info/top_level.txt +1 -0
odibi/semantics/story.py
ADDED
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Semantic Story Generation
|
|
3
|
+
=========================
|
|
4
|
+
|
|
5
|
+
Generate execution stories for semantic layer view creation.
|
|
6
|
+
|
|
7
|
+
Stories capture:
|
|
8
|
+
- View execution metadata (success/failure, duration, SQL)
|
|
9
|
+
- Graph data for lineage visualization
|
|
10
|
+
- HTML and JSON outputs for documentation
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from dataclasses import asdict, dataclass, field
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
18
|
+
|
|
19
|
+
from odibi.semantics.metrics import SemanticLayerConfig
|
|
20
|
+
from odibi.semantics.views import ViewExecutionResult, ViewGenerator
|
|
21
|
+
from odibi.utils.logging_context import get_logging_context
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class ViewExecutionMetadata:
|
|
26
|
+
"""
|
|
27
|
+
Metadata for a single view execution.
|
|
28
|
+
|
|
29
|
+
Captures execution details for documentation and debugging.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
view_name: str
|
|
33
|
+
source_table: str
|
|
34
|
+
status: str # "success", "failed"
|
|
35
|
+
duration: float
|
|
36
|
+
sql_generated: str
|
|
37
|
+
sql_file_path: Optional[str] = None
|
|
38
|
+
error_message: Optional[str] = None
|
|
39
|
+
row_count: Optional[int] = None
|
|
40
|
+
metrics_included: List[str] = field(default_factory=list)
|
|
41
|
+
dimensions_included: List[str] = field(default_factory=list)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class GraphNode:
|
|
46
|
+
"""Node in the lineage graph."""
|
|
47
|
+
|
|
48
|
+
id: str
|
|
49
|
+
type: str # "table", "view"
|
|
50
|
+
layer: str # "gold", "semantic"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class GraphEdge:
|
|
55
|
+
"""Edge in the lineage graph."""
|
|
56
|
+
|
|
57
|
+
from_node: str
|
|
58
|
+
to_node: str
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class SemanticStoryMetadata:
|
|
63
|
+
"""
|
|
64
|
+
Complete metadata for a semantic layer execution story.
|
|
65
|
+
|
|
66
|
+
Contains all information needed to generate story outputs.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
name: str
|
|
70
|
+
started_at: str
|
|
71
|
+
completed_at: str
|
|
72
|
+
duration: float
|
|
73
|
+
views: List[ViewExecutionMetadata]
|
|
74
|
+
views_created: int
|
|
75
|
+
views_failed: int
|
|
76
|
+
sql_files_saved: List[str]
|
|
77
|
+
graph_data: Dict[str, Any]
|
|
78
|
+
pipeline_layer: str = "semantic"
|
|
79
|
+
|
|
80
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
81
|
+
"""Convert to dictionary for JSON serialization."""
|
|
82
|
+
return {
|
|
83
|
+
"name": self.name,
|
|
84
|
+
"pipeline_layer": self.pipeline_layer,
|
|
85
|
+
"started_at": self.started_at,
|
|
86
|
+
"completed_at": self.completed_at,
|
|
87
|
+
"duration": self.duration,
|
|
88
|
+
"views": [asdict(v) for v in self.views],
|
|
89
|
+
"views_created": self.views_created,
|
|
90
|
+
"views_failed": self.views_failed,
|
|
91
|
+
"sql_files_saved": self.sql_files_saved,
|
|
92
|
+
"graph_data": self.graph_data,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class SemanticStoryGenerator:
|
|
97
|
+
"""
|
|
98
|
+
Generate execution stories for semantic layer operations.
|
|
99
|
+
|
|
100
|
+
Creates both JSON and HTML outputs documenting:
|
|
101
|
+
- View creation status and timing
|
|
102
|
+
- Generated SQL for each view
|
|
103
|
+
- Lineage graph connecting source tables to views
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
def __init__(
|
|
107
|
+
self,
|
|
108
|
+
config: SemanticLayerConfig,
|
|
109
|
+
name: str = "semantic_layer",
|
|
110
|
+
output_path: str = "stories/",
|
|
111
|
+
storage_options: Optional[Dict[str, Any]] = None,
|
|
112
|
+
):
|
|
113
|
+
"""
|
|
114
|
+
Initialize story generator.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
config: Semantic layer configuration
|
|
118
|
+
name: Name for this semantic layer execution
|
|
119
|
+
output_path: Directory for story output
|
|
120
|
+
storage_options: Credentials for remote storage
|
|
121
|
+
"""
|
|
122
|
+
self.config = config
|
|
123
|
+
self.name = name
|
|
124
|
+
self.output_path_str = output_path
|
|
125
|
+
self.is_remote = "://" in output_path
|
|
126
|
+
self.storage_options = storage_options or {}
|
|
127
|
+
|
|
128
|
+
self._view_generator = ViewGenerator(config)
|
|
129
|
+
self._metadata: Optional[SemanticStoryMetadata] = None
|
|
130
|
+
|
|
131
|
+
if not self.is_remote:
|
|
132
|
+
self.output_path = Path(output_path)
|
|
133
|
+
else:
|
|
134
|
+
self.output_path = None
|
|
135
|
+
|
|
136
|
+
def execute_with_story(
|
|
137
|
+
self,
|
|
138
|
+
execute_sql: Callable[[str], None],
|
|
139
|
+
save_sql_to: Optional[str] = None,
|
|
140
|
+
write_file: Optional[Callable[[str, str], None]] = None,
|
|
141
|
+
) -> SemanticStoryMetadata:
|
|
142
|
+
"""
|
|
143
|
+
Execute all views and generate a story.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
execute_sql: Callable that executes SQL against the database
|
|
147
|
+
save_sql_to: Optional path to save SQL files
|
|
148
|
+
write_file: Optional callable to write files
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
SemanticStoryMetadata with execution details
|
|
152
|
+
"""
|
|
153
|
+
ctx = get_logging_context()
|
|
154
|
+
started_at = datetime.now()
|
|
155
|
+
ctx.info("Starting semantic layer execution with story", name=self.name)
|
|
156
|
+
|
|
157
|
+
execution_result = self._view_generator.execute_all_views(
|
|
158
|
+
execute_sql=execute_sql,
|
|
159
|
+
save_sql_to=save_sql_to,
|
|
160
|
+
write_file=write_file,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
completed_at = datetime.now()
|
|
164
|
+
duration = (completed_at - started_at).total_seconds()
|
|
165
|
+
|
|
166
|
+
view_metadata = self._build_view_metadata(execution_result)
|
|
167
|
+
graph_data = self._build_graph_data(execution_result)
|
|
168
|
+
|
|
169
|
+
self._metadata = SemanticStoryMetadata(
|
|
170
|
+
name=self.name,
|
|
171
|
+
started_at=started_at.isoformat(),
|
|
172
|
+
completed_at=completed_at.isoformat(),
|
|
173
|
+
duration=duration,
|
|
174
|
+
views=view_metadata,
|
|
175
|
+
views_created=len(execution_result.views_created),
|
|
176
|
+
views_failed=len(execution_result.errors),
|
|
177
|
+
sql_files_saved=execution_result.sql_files_saved,
|
|
178
|
+
graph_data=graph_data,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
ctx.info(
|
|
182
|
+
"Semantic layer execution complete",
|
|
183
|
+
views_created=self._metadata.views_created,
|
|
184
|
+
views_failed=self._metadata.views_failed,
|
|
185
|
+
duration=duration,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
return self._metadata
|
|
189
|
+
|
|
190
|
+
def _build_view_metadata(
|
|
191
|
+
self, execution_result: ViewExecutionResult
|
|
192
|
+
) -> List[ViewExecutionMetadata]:
|
|
193
|
+
"""Build metadata for each view execution."""
|
|
194
|
+
view_metadata = []
|
|
195
|
+
|
|
196
|
+
for result in execution_result.results:
|
|
197
|
+
view_config = self._view_generator.get_view(result.name)
|
|
198
|
+
source_table = ""
|
|
199
|
+
if view_config:
|
|
200
|
+
try:
|
|
201
|
+
source_table = self._view_generator._get_source_table(view_config)
|
|
202
|
+
except ValueError:
|
|
203
|
+
source_table = "unknown"
|
|
204
|
+
|
|
205
|
+
metadata = ViewExecutionMetadata(
|
|
206
|
+
view_name=result.name,
|
|
207
|
+
source_table=source_table,
|
|
208
|
+
status="success" if result.success else "failed",
|
|
209
|
+
duration=0.0,
|
|
210
|
+
sql_generated=result.sql,
|
|
211
|
+
sql_file_path=result.sql_file_path,
|
|
212
|
+
error_message=result.error,
|
|
213
|
+
metrics_included=view_config.metrics if view_config else [],
|
|
214
|
+
dimensions_included=view_config.dimensions if view_config else [],
|
|
215
|
+
)
|
|
216
|
+
view_metadata.append(metadata)
|
|
217
|
+
|
|
218
|
+
return view_metadata
|
|
219
|
+
|
|
220
|
+
def _build_graph_data(self, execution_result: ViewExecutionResult) -> Dict[str, Any]:
|
|
221
|
+
"""Build lineage graph data."""
|
|
222
|
+
nodes = []
|
|
223
|
+
edges = []
|
|
224
|
+
seen_sources = set()
|
|
225
|
+
|
|
226
|
+
for result in execution_result.results:
|
|
227
|
+
view_config = self._view_generator.get_view(result.name)
|
|
228
|
+
if not view_config:
|
|
229
|
+
continue
|
|
230
|
+
|
|
231
|
+
nodes.append(
|
|
232
|
+
{
|
|
233
|
+
"id": result.name,
|
|
234
|
+
"type": "view",
|
|
235
|
+
"layer": "semantic",
|
|
236
|
+
}
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
try:
|
|
240
|
+
source_table = self._view_generator._get_source_table(view_config)
|
|
241
|
+
if source_table not in seen_sources:
|
|
242
|
+
# Source tables are inputs - mark as type "source"
|
|
243
|
+
# Layer will be inferred by lineage stitcher from matching nodes
|
|
244
|
+
nodes.append(
|
|
245
|
+
{
|
|
246
|
+
"id": source_table,
|
|
247
|
+
"type": "source",
|
|
248
|
+
}
|
|
249
|
+
)
|
|
250
|
+
seen_sources.add(source_table)
|
|
251
|
+
|
|
252
|
+
edges.append(
|
|
253
|
+
{
|
|
254
|
+
"from": source_table,
|
|
255
|
+
"to": result.name,
|
|
256
|
+
}
|
|
257
|
+
)
|
|
258
|
+
except ValueError:
|
|
259
|
+
pass
|
|
260
|
+
|
|
261
|
+
return {"nodes": nodes, "edges": edges}
|
|
262
|
+
|
|
263
|
+
def save_story(
|
|
264
|
+
self,
|
|
265
|
+
write_file: Optional[Callable[[str, str], None]] = None,
|
|
266
|
+
) -> Dict[str, str]:
|
|
267
|
+
"""
|
|
268
|
+
Save story as JSON and HTML files.
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
write_file: Optional callable to write files (for remote storage)
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
Dict with paths to saved files
|
|
275
|
+
"""
|
|
276
|
+
if not self._metadata:
|
|
277
|
+
raise ValueError("No story metadata. Call execute_with_story first.")
|
|
278
|
+
|
|
279
|
+
ctx = get_logging_context()
|
|
280
|
+
now = datetime.now()
|
|
281
|
+
date_str = now.strftime("%Y-%m-%d")
|
|
282
|
+
time_str = now.strftime("run_%H-%M-%S")
|
|
283
|
+
|
|
284
|
+
if self.is_remote:
|
|
285
|
+
base_path = f"{self.output_path_str.rstrip('/')}/{self.name}/{date_str}"
|
|
286
|
+
else:
|
|
287
|
+
base_path = self.output_path / self.name / date_str
|
|
288
|
+
base_path = str(base_path)
|
|
289
|
+
|
|
290
|
+
json_path = f"{base_path}/{time_str}.json"
|
|
291
|
+
html_path = f"{base_path}/{time_str}.html"
|
|
292
|
+
|
|
293
|
+
json_content = self.render_json()
|
|
294
|
+
html_content = self.render_html()
|
|
295
|
+
|
|
296
|
+
if write_file:
|
|
297
|
+
write_file(json_path, json_content)
|
|
298
|
+
write_file(html_path, html_content)
|
|
299
|
+
elif not self.is_remote:
|
|
300
|
+
Path(base_path).mkdir(parents=True, exist_ok=True)
|
|
301
|
+
Path(json_path).write_text(json_content, encoding="utf-8")
|
|
302
|
+
Path(html_path).write_text(html_content, encoding="utf-8")
|
|
303
|
+
|
|
304
|
+
ctx.info("Story saved", json_path=json_path, html_path=html_path)
|
|
305
|
+
|
|
306
|
+
return {"json": json_path, "html": html_path}
|
|
307
|
+
|
|
308
|
+
def render_json(self) -> str:
|
|
309
|
+
"""Render story as JSON string."""
|
|
310
|
+
if not self._metadata:
|
|
311
|
+
raise ValueError("No story metadata. Call execute_with_story first.")
|
|
312
|
+
return json.dumps(self._metadata.to_dict(), indent=2)
|
|
313
|
+
|
|
314
|
+
def render_html(self) -> str:
|
|
315
|
+
"""Render story as HTML string."""
|
|
316
|
+
if not self._metadata:
|
|
317
|
+
raise ValueError("No story metadata. Call execute_with_story first.")
|
|
318
|
+
|
|
319
|
+
meta = self._metadata
|
|
320
|
+
|
|
321
|
+
status_class = "success" if meta.views_failed == 0 else "warning"
|
|
322
|
+
status_text = "Success" if meta.views_failed == 0 else "Partial Failure"
|
|
323
|
+
|
|
324
|
+
views_html = []
|
|
325
|
+
for view in meta.views:
|
|
326
|
+
view_status = "✅" if view.status == "success" else "❌"
|
|
327
|
+
error_html = ""
|
|
328
|
+
if view.error_message:
|
|
329
|
+
error_html = f'<div class="error">{view.error_message}</div>'
|
|
330
|
+
|
|
331
|
+
sql_html = f"<pre><code>{self._escape_html(view.sql_generated)}</code></pre>"
|
|
332
|
+
|
|
333
|
+
views_html.append(
|
|
334
|
+
f"""
|
|
335
|
+
<div class="view-card {view.status}">
|
|
336
|
+
<h3>{view_status} {view.view_name}</h3>
|
|
337
|
+
<div class="view-details">
|
|
338
|
+
<p><strong>Source:</strong> {view.source_table}</p>
|
|
339
|
+
<p><strong>Metrics:</strong> {", ".join(view.metrics_included)}</p>
|
|
340
|
+
<p><strong>Dimensions:</strong> {", ".join(view.dimensions_included)}</p>
|
|
341
|
+
{error_html}
|
|
342
|
+
</div>
|
|
343
|
+
<details>
|
|
344
|
+
<summary>Generated SQL</summary>
|
|
345
|
+
{sql_html}
|
|
346
|
+
</details>
|
|
347
|
+
</div>
|
|
348
|
+
"""
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
mermaid_code = self._generate_mermaid_diagram()
|
|
352
|
+
|
|
353
|
+
html = f"""<!DOCTYPE html>
|
|
354
|
+
<html lang="en">
|
|
355
|
+
<head>
|
|
356
|
+
<meta charset="UTF-8">
|
|
357
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
358
|
+
<title>Semantic Layer Story: {meta.name}</title>
|
|
359
|
+
<script src="https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js"></script>
|
|
360
|
+
<style>
|
|
361
|
+
:root {{
|
|
362
|
+
--primary: #2563eb;
|
|
363
|
+
--success: #16a34a;
|
|
364
|
+
--warning: #dc2626;
|
|
365
|
+
--bg: #f8fafc;
|
|
366
|
+
--card-bg: #ffffff;
|
|
367
|
+
--text: #1e293b;
|
|
368
|
+
--border: #e2e8f0;
|
|
369
|
+
}}
|
|
370
|
+
body {{
|
|
371
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
372
|
+
background: var(--bg);
|
|
373
|
+
color: var(--text);
|
|
374
|
+
margin: 0;
|
|
375
|
+
padding: 20px;
|
|
376
|
+
line-height: 1.6;
|
|
377
|
+
}}
|
|
378
|
+
.container {{ max-width: 1200px; margin: 0 auto; }}
|
|
379
|
+
h1 {{ color: var(--primary); margin-bottom: 0; }}
|
|
380
|
+
.subtitle {{ color: #64748b; margin-top: 5px; }}
|
|
381
|
+
.summary {{
|
|
382
|
+
display: grid;
|
|
383
|
+
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
|
384
|
+
gap: 15px;
|
|
385
|
+
margin: 20px 0;
|
|
386
|
+
}}
|
|
387
|
+
.stat {{
|
|
388
|
+
background: var(--card-bg);
|
|
389
|
+
padding: 15px;
|
|
390
|
+
border-radius: 8px;
|
|
391
|
+
border: 1px solid var(--border);
|
|
392
|
+
text-align: center;
|
|
393
|
+
}}
|
|
394
|
+
.stat-value {{ font-size: 24px; font-weight: bold; color: var(--primary); }}
|
|
395
|
+
.stat-label {{ font-size: 12px; color: #64748b; text-transform: uppercase; }}
|
|
396
|
+
.status-badge {{
|
|
397
|
+
display: inline-block;
|
|
398
|
+
padding: 4px 12px;
|
|
399
|
+
border-radius: 20px;
|
|
400
|
+
font-size: 14px;
|
|
401
|
+
font-weight: 500;
|
|
402
|
+
}}
|
|
403
|
+
.status-badge.success {{ background: #dcfce7; color: var(--success); }}
|
|
404
|
+
.status-badge.warning {{ background: #fee2e2; color: var(--warning); }}
|
|
405
|
+
.view-card {{
|
|
406
|
+
background: var(--card-bg);
|
|
407
|
+
border: 1px solid var(--border);
|
|
408
|
+
border-radius: 8px;
|
|
409
|
+
padding: 15px;
|
|
410
|
+
margin: 15px 0;
|
|
411
|
+
}}
|
|
412
|
+
.view-card.success {{ border-left: 4px solid var(--success); }}
|
|
413
|
+
.view-card.failed {{ border-left: 4px solid var(--warning); }}
|
|
414
|
+
.view-card h3 {{ margin: 0 0 10px 0; }}
|
|
415
|
+
.view-details p {{ margin: 5px 0; }}
|
|
416
|
+
.error {{ background: #fee2e2; color: var(--warning); padding: 10px; border-radius: 4px; margin-top: 10px; }}
|
|
417
|
+
details {{ margin-top: 10px; }}
|
|
418
|
+
summary {{ cursor: pointer; font-weight: 500; color: var(--primary); }}
|
|
419
|
+
pre {{
|
|
420
|
+
background: #1e293b;
|
|
421
|
+
color: #e2e8f0;
|
|
422
|
+
padding: 15px;
|
|
423
|
+
border-radius: 8px;
|
|
424
|
+
overflow-x: auto;
|
|
425
|
+
font-size: 13px;
|
|
426
|
+
}}
|
|
427
|
+
.lineage {{ background: var(--card-bg); padding: 20px; border-radius: 8px; border: 1px solid var(--border); margin: 20px 0; }}
|
|
428
|
+
.mermaid {{ text-align: center; }}
|
|
429
|
+
</style>
|
|
430
|
+
</head>
|
|
431
|
+
<body>
|
|
432
|
+
<div class="container">
|
|
433
|
+
<h1>🔷 {meta.name}</h1>
|
|
434
|
+
<p class="subtitle">Semantic Layer Execution Story</p>
|
|
435
|
+
|
|
436
|
+
<div class="summary">
|
|
437
|
+
<div class="stat">
|
|
438
|
+
<div class="stat-value">{meta.views_created}</div>
|
|
439
|
+
<div class="stat-label">Views Created</div>
|
|
440
|
+
</div>
|
|
441
|
+
<div class="stat">
|
|
442
|
+
<div class="stat-value">{meta.views_failed}</div>
|
|
443
|
+
<div class="stat-label">Failed</div>
|
|
444
|
+
</div>
|
|
445
|
+
<div class="stat">
|
|
446
|
+
<div class="stat-value">{meta.duration:.2f}s</div>
|
|
447
|
+
<div class="stat-label">Duration</div>
|
|
448
|
+
</div>
|
|
449
|
+
<div class="stat">
|
|
450
|
+
<span class="status-badge {status_class}">{status_text}</span>
|
|
451
|
+
</div>
|
|
452
|
+
</div>
|
|
453
|
+
|
|
454
|
+
<h2>📊 Lineage</h2>
|
|
455
|
+
<div class="lineage">
|
|
456
|
+
<div class="mermaid">
|
|
457
|
+
{mermaid_code}
|
|
458
|
+
</div>
|
|
459
|
+
</div>
|
|
460
|
+
|
|
461
|
+
<h2>📋 Views</h2>
|
|
462
|
+
{"".join(views_html)}
|
|
463
|
+
|
|
464
|
+
<footer style="text-align: center; color: #94a3b8; margin-top: 40px; font-size: 12px;">
|
|
465
|
+
Generated: {meta.completed_at} | Duration: {meta.duration:.2f}s
|
|
466
|
+
</footer>
|
|
467
|
+
</div>
|
|
468
|
+
<script>mermaid.initialize({{startOnLoad: true, theme: 'neutral'}});</script>
|
|
469
|
+
</body>
|
|
470
|
+
</html>"""
|
|
471
|
+
|
|
472
|
+
return html
|
|
473
|
+
|
|
474
|
+
def _generate_mermaid_diagram(self) -> str:
|
|
475
|
+
"""Generate Mermaid flowchart from graph data."""
|
|
476
|
+
if not self._metadata:
|
|
477
|
+
return "graph LR\n A[No Data]"
|
|
478
|
+
|
|
479
|
+
lines = ["graph LR"]
|
|
480
|
+
|
|
481
|
+
for node in self._metadata.graph_data.get("nodes", []):
|
|
482
|
+
node_id = node["id"].replace(".", "_").replace("-", "_")
|
|
483
|
+
if node["type"] == "table":
|
|
484
|
+
lines.append(f' {node_id}[("{node["id"]}")]')
|
|
485
|
+
else:
|
|
486
|
+
lines.append(f' {node_id}["{node["id"]}"]')
|
|
487
|
+
|
|
488
|
+
for edge in self._metadata.graph_data.get("edges", []):
|
|
489
|
+
from_id = edge["from"].replace(".", "_").replace("-", "_")
|
|
490
|
+
to_id = edge["to"].replace(".", "_").replace("-", "_")
|
|
491
|
+
lines.append(f" {from_id} --> {to_id}")
|
|
492
|
+
|
|
493
|
+
return "\n".join(lines)
|
|
494
|
+
|
|
495
|
+
def _escape_html(self, text: str) -> str:
|
|
496
|
+
"""Escape HTML special characters."""
|
|
497
|
+
return (
|
|
498
|
+
text.replace("&", "&")
|
|
499
|
+
.replace("<", "<")
|
|
500
|
+
.replace(">", ">")
|
|
501
|
+
.replace('"', """)
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
@property
|
|
505
|
+
def metadata(self) -> Optional[SemanticStoryMetadata]:
|
|
506
|
+
"""Get the last generated metadata."""
|
|
507
|
+
return self._metadata
|