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.
Files changed (124) hide show
  1. odibi/__init__.py +32 -0
  2. odibi/__main__.py +8 -0
  3. odibi/catalog.py +3011 -0
  4. odibi/cli/__init__.py +11 -0
  5. odibi/cli/__main__.py +6 -0
  6. odibi/cli/catalog.py +553 -0
  7. odibi/cli/deploy.py +69 -0
  8. odibi/cli/doctor.py +161 -0
  9. odibi/cli/export.py +66 -0
  10. odibi/cli/graph.py +150 -0
  11. odibi/cli/init_pipeline.py +242 -0
  12. odibi/cli/lineage.py +259 -0
  13. odibi/cli/main.py +215 -0
  14. odibi/cli/run.py +98 -0
  15. odibi/cli/schema.py +208 -0
  16. odibi/cli/secrets.py +232 -0
  17. odibi/cli/story.py +379 -0
  18. odibi/cli/system.py +132 -0
  19. odibi/cli/test.py +286 -0
  20. odibi/cli/ui.py +31 -0
  21. odibi/cli/validate.py +39 -0
  22. odibi/config.py +3541 -0
  23. odibi/connections/__init__.py +9 -0
  24. odibi/connections/azure_adls.py +499 -0
  25. odibi/connections/azure_sql.py +709 -0
  26. odibi/connections/base.py +28 -0
  27. odibi/connections/factory.py +322 -0
  28. odibi/connections/http.py +78 -0
  29. odibi/connections/local.py +119 -0
  30. odibi/connections/local_dbfs.py +61 -0
  31. odibi/constants.py +17 -0
  32. odibi/context.py +528 -0
  33. odibi/diagnostics/__init__.py +12 -0
  34. odibi/diagnostics/delta.py +520 -0
  35. odibi/diagnostics/diff.py +169 -0
  36. odibi/diagnostics/manager.py +171 -0
  37. odibi/engine/__init__.py +20 -0
  38. odibi/engine/base.py +334 -0
  39. odibi/engine/pandas_engine.py +2178 -0
  40. odibi/engine/polars_engine.py +1114 -0
  41. odibi/engine/registry.py +54 -0
  42. odibi/engine/spark_engine.py +2362 -0
  43. odibi/enums.py +7 -0
  44. odibi/exceptions.py +297 -0
  45. odibi/graph.py +426 -0
  46. odibi/introspect.py +1214 -0
  47. odibi/lineage.py +511 -0
  48. odibi/node.py +3341 -0
  49. odibi/orchestration/__init__.py +0 -0
  50. odibi/orchestration/airflow.py +90 -0
  51. odibi/orchestration/dagster.py +77 -0
  52. odibi/patterns/__init__.py +24 -0
  53. odibi/patterns/aggregation.py +599 -0
  54. odibi/patterns/base.py +94 -0
  55. odibi/patterns/date_dimension.py +423 -0
  56. odibi/patterns/dimension.py +696 -0
  57. odibi/patterns/fact.py +748 -0
  58. odibi/patterns/merge.py +128 -0
  59. odibi/patterns/scd2.py +148 -0
  60. odibi/pipeline.py +2382 -0
  61. odibi/plugins.py +80 -0
  62. odibi/project.py +581 -0
  63. odibi/references.py +151 -0
  64. odibi/registry.py +246 -0
  65. odibi/semantics/__init__.py +71 -0
  66. odibi/semantics/materialize.py +392 -0
  67. odibi/semantics/metrics.py +361 -0
  68. odibi/semantics/query.py +743 -0
  69. odibi/semantics/runner.py +430 -0
  70. odibi/semantics/story.py +507 -0
  71. odibi/semantics/views.py +432 -0
  72. odibi/state/__init__.py +1203 -0
  73. odibi/story/__init__.py +55 -0
  74. odibi/story/doc_story.py +554 -0
  75. odibi/story/generator.py +1431 -0
  76. odibi/story/lineage.py +1043 -0
  77. odibi/story/lineage_utils.py +324 -0
  78. odibi/story/metadata.py +608 -0
  79. odibi/story/renderers.py +453 -0
  80. odibi/story/templates/run_story.html +2520 -0
  81. odibi/story/themes.py +216 -0
  82. odibi/testing/__init__.py +13 -0
  83. odibi/testing/assertions.py +75 -0
  84. odibi/testing/fixtures.py +85 -0
  85. odibi/testing/source_pool.py +277 -0
  86. odibi/transformers/__init__.py +122 -0
  87. odibi/transformers/advanced.py +1472 -0
  88. odibi/transformers/delete_detection.py +610 -0
  89. odibi/transformers/manufacturing.py +1029 -0
  90. odibi/transformers/merge_transformer.py +778 -0
  91. odibi/transformers/relational.py +675 -0
  92. odibi/transformers/scd.py +579 -0
  93. odibi/transformers/sql_core.py +1356 -0
  94. odibi/transformers/validation.py +165 -0
  95. odibi/ui/__init__.py +0 -0
  96. odibi/ui/app.py +195 -0
  97. odibi/utils/__init__.py +66 -0
  98. odibi/utils/alerting.py +667 -0
  99. odibi/utils/config_loader.py +343 -0
  100. odibi/utils/console.py +231 -0
  101. odibi/utils/content_hash.py +202 -0
  102. odibi/utils/duration.py +43 -0
  103. odibi/utils/encoding.py +102 -0
  104. odibi/utils/extensions.py +28 -0
  105. odibi/utils/hashing.py +61 -0
  106. odibi/utils/logging.py +203 -0
  107. odibi/utils/logging_context.py +740 -0
  108. odibi/utils/progress.py +429 -0
  109. odibi/utils/setup_helpers.py +302 -0
  110. odibi/utils/telemetry.py +140 -0
  111. odibi/validation/__init__.py +62 -0
  112. odibi/validation/engine.py +765 -0
  113. odibi/validation/explanation_linter.py +155 -0
  114. odibi/validation/fk.py +547 -0
  115. odibi/validation/gate.py +252 -0
  116. odibi/validation/quarantine.py +605 -0
  117. odibi/writers/__init__.py +15 -0
  118. odibi/writers/sql_server_writer.py +2081 -0
  119. odibi-2.5.0.dist-info/METADATA +255 -0
  120. odibi-2.5.0.dist-info/RECORD +124 -0
  121. odibi-2.5.0.dist-info/WHEEL +5 -0
  122. odibi-2.5.0.dist-info/entry_points.txt +2 -0
  123. odibi-2.5.0.dist-info/licenses/LICENSE +190 -0
  124. odibi-2.5.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,453 @@
1
+ """
2
+ Story Renderers
3
+ ===============
4
+
5
+ Renders pipeline stories in different output formats (HTML, Markdown, JSON).
6
+ """
7
+
8
+ import json
9
+ from pathlib import Path
10
+ from typing import Optional
11
+
12
+ import odibi
13
+ from odibi.story.metadata import PipelineStoryMetadata
14
+ from odibi.utils.logging_context import get_logging_context
15
+
16
+
17
+ class HTMLStoryRenderer:
18
+ """
19
+ Renders pipeline stories as HTML.
20
+
21
+ Creates professional, interactive HTML reports with:
22
+ - Responsive design
23
+ - Collapsible sections
24
+ - Status indicators
25
+ - Summary statistics
26
+ """
27
+
28
+ def __init__(self, template_path: Optional[str] = None, theme=None):
29
+ """
30
+ Initialize HTML renderer.
31
+
32
+ Args:
33
+ template_path: Path to custom template (uses default if None)
34
+ theme: StoryTheme instance or None for default
35
+ """
36
+ self.template_path = template_path or self._default_template_path()
37
+ self.theme = theme
38
+
39
+ def _default_template_path(self) -> Path:
40
+ """Get path to default HTML template."""
41
+ return Path(__file__).parent / "templates" / "run_story.html"
42
+
43
+ def render(self, metadata: PipelineStoryMetadata) -> str:
44
+ """
45
+ Render story as HTML.
46
+
47
+ Args:
48
+ metadata: Pipeline story metadata
49
+
50
+ Returns:
51
+ HTML string
52
+ """
53
+ ctx = get_logging_context()
54
+ ctx.debug(
55
+ "Rendering HTML story",
56
+ template=str(self.template_path),
57
+ pipeline=metadata.pipeline_name,
58
+ )
59
+
60
+ try:
61
+ from jinja2 import Template
62
+ except ImportError as e:
63
+ ctx.error("jinja2 not installed for HTML rendering")
64
+ raise ImportError(
65
+ "jinja2 is required for HTML rendering. Install with: pip install jinja2"
66
+ ) from e
67
+
68
+ try:
69
+ # Load template
70
+ with open(self.template_path, "r", encoding="utf-8") as f:
71
+ template_content = f.read()
72
+
73
+ # Apply theme if provided
74
+ if self.theme:
75
+ # Inject theme CSS variables into template
76
+ theme_css = self.theme.to_css_string()
77
+ template_content = template_content.replace(
78
+ ":root {", theme_css.split("}")[0] + "}"
79
+ )
80
+
81
+ template = Template(template_content)
82
+
83
+ # Register custom filters
84
+ # Note: Template creates its own environment, so we attach to that
85
+ template.environment.filters["to_yaml"] = self._to_yaml
86
+ template.environment.filters["format_run_id"] = self._format_run_id
87
+
88
+ # Render with metadata, theme, and version
89
+ html = template.render(
90
+ metadata=metadata,
91
+ theme=self.theme,
92
+ odibi_version=odibi.__version__,
93
+ )
94
+
95
+ ctx.debug(
96
+ "HTML story rendered",
97
+ output_format="html",
98
+ size=len(html),
99
+ nodes=len(metadata.nodes),
100
+ )
101
+
102
+ return html
103
+ except Exception as e:
104
+ ctx.error(
105
+ "HTML template rendering failed",
106
+ template=str(self.template_path),
107
+ error=str(e),
108
+ )
109
+ raise
110
+
111
+ def _to_yaml(self, value) -> str:
112
+ """Convert value to YAML string."""
113
+ import yaml
114
+
115
+ if not value:
116
+ return ""
117
+ return yaml.dump(value, sort_keys=False, default_flow_style=False).strip()
118
+
119
+ def _format_run_id(self, run_id: str) -> str:
120
+ """Format run_id (YYYYMMDD_HHMMSS) to human-readable format."""
121
+ from datetime import datetime
122
+
123
+ if not run_id or len(run_id) < 15:
124
+ return run_id or ""
125
+
126
+ try:
127
+ dt = datetime.strptime(run_id[:15], "%Y%m%d_%H%M%S")
128
+ return dt.strftime("%b %d, %I:%M %p UTC").replace(" 0", " ").lstrip("0")
129
+ except ValueError:
130
+ return run_id
131
+
132
+ def render_to_file(self, metadata: PipelineStoryMetadata, output_path: str) -> str:
133
+ """
134
+ Render story and save to file.
135
+
136
+ Args:
137
+ metadata: Pipeline story metadata
138
+ output_path: Path to save HTML file
139
+
140
+ Returns:
141
+ Path to saved file
142
+ """
143
+ ctx = get_logging_context()
144
+ html = self.render(metadata)
145
+
146
+ output_file = Path(output_path)
147
+ output_file.parent.mkdir(parents=True, exist_ok=True)
148
+
149
+ try:
150
+ with open(output_file, "w", encoding="utf-8") as f:
151
+ f.write(html)
152
+ ctx.debug(
153
+ "HTML story written to file",
154
+ path=str(output_file),
155
+ size=len(html),
156
+ )
157
+ except Exception as e:
158
+ ctx.error("Failed to write HTML story", path=str(output_file), error=str(e))
159
+ raise
160
+
161
+ return str(output_file)
162
+
163
+
164
+ class MarkdownStoryRenderer:
165
+ """
166
+ Renders pipeline stories as Markdown.
167
+
168
+ Creates clean, readable Markdown documentation with:
169
+ - GitHub-flavored markdown
170
+ - Tables for data
171
+ - Code blocks for errors
172
+ - Emoji indicators
173
+ """
174
+
175
+ def render(self, metadata: PipelineStoryMetadata) -> str:
176
+ """
177
+ Render story as Markdown.
178
+
179
+ Args:
180
+ metadata: Pipeline story metadata
181
+
182
+ Returns:
183
+ Markdown string
184
+ """
185
+ ctx = get_logging_context()
186
+ ctx.debug(
187
+ "Rendering Markdown story",
188
+ output_format="markdown",
189
+ pipeline=metadata.pipeline_name,
190
+ )
191
+
192
+ lines = []
193
+
194
+ # Header
195
+ lines.append(f"# 📊 Pipeline Run Story: {metadata.pipeline_name}")
196
+ lines.append("")
197
+
198
+ # Execution info
199
+ lines.append(f"**Started:** {metadata.started_at}")
200
+ lines.append(f"**Completed:** {metadata.completed_at or 'Running...'}")
201
+ lines.append(f"**Duration:** {metadata.duration:.2f}s")
202
+
203
+ # Status
204
+ if metadata.failed_nodes > 0:
205
+ status = f"❌ Failed ({metadata.failed_nodes} node{'s' if metadata.failed_nodes != 1 else ''})"
206
+ elif metadata.completed_nodes == metadata.total_nodes:
207
+ status = f"✅ Success ({metadata.completed_nodes}/{metadata.total_nodes} nodes)"
208
+ else:
209
+ status = f"⚠️ Partial ({metadata.completed_nodes}/{metadata.total_nodes} completed)"
210
+
211
+ lines.append(f"**Status:** {status}")
212
+ lines.append("")
213
+
214
+ # Project context
215
+ if metadata.pipeline_layer:
216
+ lines.append(f"**Layer:** {metadata.pipeline_layer}")
217
+
218
+ if metadata.project:
219
+ context_parts = [f"**Project:** {metadata.project}"]
220
+ if metadata.plant:
221
+ context_parts.append(f"**Plant:** {metadata.plant}")
222
+ if metadata.asset:
223
+ context_parts.append(f"**Asset:** {metadata.asset}")
224
+ lines.append(" | ".join(context_parts))
225
+ lines.append("")
226
+
227
+ lines.append("---")
228
+ lines.append("")
229
+
230
+ # Summary
231
+ lines.append("## Summary")
232
+ lines.append("")
233
+ lines.append(f"- ✅ **Completed:** {metadata.completed_nodes} nodes")
234
+ lines.append(f"- ❌ **Failed:** {metadata.failed_nodes} nodes")
235
+ lines.append(f"- ⏭️ **Skipped:** {metadata.skipped_nodes} nodes")
236
+ lines.append(f"- 📊 **Success Rate:** {metadata.get_success_rate():.1f}%")
237
+ lines.append(f"- 📈 **Total Rows Processed:** {metadata.get_total_rows_processed():,}")
238
+ lines.append(f"- ⏱️ **Duration:** {metadata.duration:.2f}s")
239
+ lines.append("")
240
+ lines.append("---")
241
+ lines.append("")
242
+
243
+ # Node details
244
+ lines.append("## Node Execution Details")
245
+ lines.append("")
246
+
247
+ for node in metadata.nodes:
248
+ # Node header
249
+ status_icon = {"success": "✅", "failed": "❌", "skipped": "⏭️"}.get(node.status, "❓")
250
+ lines.append(f"### {status_icon} {node.node_name}")
251
+ lines.append("")
252
+ lines.append(f"**Operation:** `{node.operation}`")
253
+ lines.append(f"**Duration:** {node.duration:.4f}s")
254
+
255
+ # Historical Context
256
+ if node.historical_avg_duration and node.duration is not None:
257
+ diff = node.duration - node.historical_avg_duration
258
+ icon = "🔼" if diff > 0 else "🔽"
259
+ lines.append(
260
+ f"**Avg Duration (7d):** {node.historical_avg_duration:.4f}s ({icon} {abs(diff):.4f}s)"
261
+ )
262
+
263
+ # Data metrics
264
+ if node.rows_in is not None:
265
+ lines.append(f"**Rows Read:** {node.rows_in:,}")
266
+ if node.rows_out is not None:
267
+ lines.append(f"**Rows Out:** {node.rows_out:,}")
268
+ if node.rows_written is not None:
269
+ if node.rows_in is not None and node.rows_written == 0 and node.rows_in > 0:
270
+ lines.append(f"**Rows Written:** {node.rows_written:,} (no changes detected)")
271
+ else:
272
+ lines.append(f"**Rows Written:** {node.rows_written:,}")
273
+
274
+ # Historical Rows
275
+ if node.historical_avg_rows is not None and node.rows_out is not None:
276
+ diff = node.rows_out - node.historical_avg_rows
277
+ icon = "🔼" if diff > 0 else "🔽"
278
+ lines.append(
279
+ f"**Avg Rows (7d):** ~{int(node.historical_avg_rows):,} ({icon} {int(abs(diff)):,})"
280
+ )
281
+
282
+ if node.rows_change is not None:
283
+ change_sign = "+" if node.rows_change > 0 else ""
284
+ lines.append(
285
+ f"**Row Change:** {change_sign}{node.rows_change:,} "
286
+ f"({node.rows_change_pct:+.1f}%)"
287
+ )
288
+
289
+ # Validation Warnings
290
+ if node.validation_warnings:
291
+ lines.append("")
292
+ lines.append("**Validation Warnings:**")
293
+ for warning in node.validation_warnings:
294
+ lines.append(f"- ⚠️ {warning}")
295
+
296
+ # Schema changes
297
+ if node.columns_added or node.columns_removed:
298
+ lines.append("")
299
+ lines.append("**Schema Changes:**")
300
+ for col in node.columns_added:
301
+ lines.append(f"- ➕ Added: `{col}`")
302
+ for col in node.columns_removed:
303
+ lines.append(f"- ➖ Removed: `{col}`")
304
+
305
+ # Error details
306
+ if node.error_message:
307
+ lines.append("")
308
+ lines.append(f"**Error:** {node.error_type or 'Exception'}")
309
+ lines.append("```")
310
+ lines.append(node.error_message)
311
+ lines.append("```")
312
+
313
+ lines.append("")
314
+ lines.append("---")
315
+ lines.append("")
316
+
317
+ result = "\n".join(lines)
318
+ ctx.debug(
319
+ "Markdown story rendered",
320
+ output_format="markdown",
321
+ size=len(result),
322
+ nodes=len(metadata.nodes),
323
+ )
324
+ return result
325
+
326
+ def render_to_file(self, metadata: PipelineStoryMetadata, output_path: str) -> str:
327
+ """
328
+ Render story and save to file.
329
+
330
+ Args:
331
+ metadata: Pipeline story metadata
332
+ output_path: Path to save Markdown file
333
+
334
+ Returns:
335
+ Path to saved file
336
+ """
337
+ ctx = get_logging_context()
338
+ markdown = self.render(metadata)
339
+
340
+ output_file = Path(output_path)
341
+ output_file.parent.mkdir(parents=True, exist_ok=True)
342
+
343
+ try:
344
+ with open(output_file, "w", encoding="utf-8") as f:
345
+ f.write(markdown)
346
+ ctx.debug(
347
+ "Markdown story written to file",
348
+ path=str(output_file),
349
+ size=len(markdown),
350
+ )
351
+ except Exception as e:
352
+ ctx.error("Failed to write Markdown story", path=str(output_file), error=str(e))
353
+ raise
354
+
355
+ return str(output_file)
356
+
357
+
358
+ class JSONStoryRenderer:
359
+ """
360
+ Renders pipeline stories as JSON.
361
+
362
+ Creates machine-readable JSON output for:
363
+ - API integration
364
+ - Programmatic analysis
365
+ - Data storage/archival
366
+ """
367
+
368
+ def render(self, metadata: PipelineStoryMetadata) -> str:
369
+ """
370
+ Render story as JSON.
371
+
372
+ Args:
373
+ metadata: Pipeline story metadata
374
+
375
+ Returns:
376
+ JSON string
377
+ """
378
+ ctx = get_logging_context()
379
+ ctx.debug(
380
+ "Rendering JSON story",
381
+ output_format="json",
382
+ pipeline=metadata.pipeline_name,
383
+ )
384
+
385
+ result = json.dumps(metadata.to_dict(), indent=2, default=str)
386
+
387
+ ctx.debug(
388
+ "JSON story rendered",
389
+ output_format="json",
390
+ size=len(result),
391
+ nodes=len(metadata.nodes),
392
+ )
393
+ return result
394
+
395
+ def render_to_file(self, metadata: PipelineStoryMetadata, output_path: str) -> str:
396
+ """
397
+ Render story and save to file.
398
+
399
+ Args:
400
+ metadata: Pipeline story metadata
401
+ output_path: Path to save JSON file
402
+
403
+ Returns:
404
+ Path to saved file
405
+ """
406
+ ctx = get_logging_context()
407
+ json_str = self.render(metadata)
408
+
409
+ output_file = Path(output_path)
410
+ output_file.parent.mkdir(parents=True, exist_ok=True)
411
+
412
+ try:
413
+ with open(output_file, "w", encoding="utf-8") as f:
414
+ f.write(json_str)
415
+ ctx.debug(
416
+ "JSON story written to file",
417
+ path=str(output_file),
418
+ size=len(json_str),
419
+ )
420
+ except Exception as e:
421
+ ctx.error("Failed to write JSON story", path=str(output_file), error=str(e))
422
+ raise
423
+
424
+ return str(output_file)
425
+
426
+
427
+ def get_renderer(format: str):
428
+ """
429
+ Get renderer for specified format.
430
+
431
+ Args:
432
+ format: Output format ("html", "markdown", "json")
433
+
434
+ Returns:
435
+ Renderer instance
436
+
437
+ Raises:
438
+ ValueError: If format is not supported
439
+ """
440
+ renderers = {
441
+ "html": HTMLStoryRenderer,
442
+ "markdown": MarkdownStoryRenderer,
443
+ "md": MarkdownStoryRenderer,
444
+ "json": JSONStoryRenderer,
445
+ }
446
+
447
+ renderer_class = renderers.get(format.lower())
448
+ if not renderer_class:
449
+ raise ValueError(
450
+ f"Unsupported format: {format}. Supported formats: {', '.join(renderers.keys())}"
451
+ )
452
+
453
+ return renderer_class()