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/story/renderers.py
ADDED
|
@@ -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()
|