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,55 @@
1
+ """
2
+ Story Generation Module
3
+ =======================
4
+
5
+ Provides automatic documentation and audit trail generation for pipeline runs.
6
+
7
+ Components:
8
+ - metadata: Story metadata tracking
9
+ - generator: Core story generation logic
10
+ - renderers: HTML/Markdown/JSON output formatters
11
+ """
12
+
13
+ from odibi.story.doc_story import DocStoryGenerator
14
+ from odibi.story.generator import StoryGenerator
15
+ from odibi.story.lineage import LineageGenerator, LineageResult
16
+ from odibi.story.metadata import (
17
+ NodeExecutionMetadata,
18
+ PipelineStoryMetadata,
19
+ )
20
+ from odibi.story.renderers import (
21
+ HTMLStoryRenderer,
22
+ JSONStoryRenderer,
23
+ MarkdownStoryRenderer,
24
+ get_renderer,
25
+ )
26
+ from odibi.story.themes import (
27
+ CORPORATE_THEME,
28
+ DARK_THEME,
29
+ DEFAULT_THEME,
30
+ MINIMAL_THEME,
31
+ StoryTheme,
32
+ get_theme,
33
+ list_themes,
34
+ )
35
+
36
+ __all__ = [
37
+ "NodeExecutionMetadata",
38
+ "PipelineStoryMetadata",
39
+ "StoryGenerator",
40
+ "HTMLStoryRenderer",
41
+ "MarkdownStoryRenderer",
42
+ "JSONStoryRenderer",
43
+ "get_renderer",
44
+ "DocStoryGenerator",
45
+ "LineageGenerator",
46
+ "LineageResult",
47
+ "StoryTheme",
48
+ "get_theme",
49
+ "list_themes",
50
+ "DEFAULT_THEME",
51
+ "CORPORATE_THEME",
52
+ "DARK_THEME",
53
+ "MINIMAL_THEME",
54
+ ]
55
+ __version__ = "1.3.0-alpha.6-phase4"
@@ -0,0 +1,554 @@
1
+ """
2
+ Documentation Story Generator
3
+ ==============================
4
+
5
+ Generates stakeholder-ready documentation from pipeline configurations.
6
+ Automatically extracts explanations from registered operations.
7
+ """
8
+
9
+ from pathlib import Path
10
+ from typing import Any, Dict, List, Optional
11
+
12
+ import odibi
13
+ from odibi.config import NodeConfig, PipelineConfig, ProjectConfig
14
+ from odibi.validation import ExplanationLinter
15
+
16
+
17
+ class DocStoryGenerator:
18
+ """
19
+ Generates documentation stories for pipelines.
20
+
21
+ Creates stakeholder-ready documentation by:
22
+ - Extracting operation explanations from the registry
23
+ - Building context from pipeline/project config
24
+ - Validating explanation quality
25
+ - Rendering to HTML or Markdown
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ pipeline_config: PipelineConfig,
31
+ project_config: Optional[ProjectConfig] = None,
32
+ ):
33
+ """
34
+ Initialize doc story generator.
35
+
36
+ Args:
37
+ pipeline_config: Pipeline configuration
38
+ project_config: Optional project configuration for context
39
+ """
40
+ self.pipeline_config = pipeline_config
41
+ self.project_config = project_config
42
+ self.registry = None # Registry logic removed/deprecated in cleanup
43
+ self.linter = ExplanationLinter()
44
+
45
+ def generate(
46
+ self,
47
+ output_path: str,
48
+ format: str = "html",
49
+ validate: bool = True,
50
+ include_flow_diagram: bool = True,
51
+ theme=None,
52
+ ) -> str:
53
+ """
54
+ Generate documentation story.
55
+
56
+ Args:
57
+ output_path: Path to save documentation
58
+ format: Output format ("html" or "markdown")
59
+ validate: Whether to validate explanation quality
60
+ include_flow_diagram: Whether to include flow diagram
61
+ theme: StoryTheme instance for HTML rendering (optional)
62
+
63
+ Returns:
64
+ Path to generated documentation file
65
+
66
+ Raises:
67
+ ValueError: If validation fails and validate=True
68
+ """
69
+ # Build documentation structure
70
+ doc_data = {
71
+ "title": self._generate_title(),
72
+ "overview": self._generate_overview(),
73
+ "operations": self._generate_operation_details(validate),
74
+ "expected_outputs": self._generate_outputs(),
75
+ }
76
+
77
+ if include_flow_diagram:
78
+ doc_data["flow_diagram"] = self._generate_flow_diagram()
79
+
80
+ # Render to requested format
81
+ if format.lower() == "html":
82
+ return self._render_html(doc_data, output_path, theme)
83
+ elif format.lower() in ["markdown", "md"]:
84
+ return self._render_markdown(doc_data, output_path)
85
+ elif format.lower() == "json":
86
+ return self._render_json(doc_data, output_path)
87
+ else:
88
+ raise ValueError(f"Unsupported format: {format}")
89
+
90
+ def _generate_title(self) -> str:
91
+ """Generate documentation title."""
92
+ title = f"Pipeline Documentation: {self.pipeline_config.pipeline}"
93
+
94
+ if self.project_config:
95
+ if self.project_config.project:
96
+ title = f"{self.project_config.project} - {title}"
97
+
98
+ return title
99
+
100
+ def _generate_overview(self) -> Dict[str, Any]:
101
+ """Generate pipeline overview section."""
102
+ overview = {
103
+ "pipeline_name": self.pipeline_config.pipeline,
104
+ "description": self.pipeline_config.description or "No description provided",
105
+ "total_nodes": len(self.pipeline_config.nodes),
106
+ }
107
+
108
+ # Add project context if available
109
+ if self.project_config:
110
+ overview["project"] = self.project_config.project
111
+ overview["plant"] = getattr(self.project_config, "plant", None)
112
+ overview["asset"] = getattr(self.project_config, "asset", None)
113
+ overview["business_unit"] = getattr(self.project_config, "business_unit", None)
114
+
115
+ # Add layer info if available
116
+ if hasattr(self.pipeline_config, "layer"):
117
+ overview["layer"] = self.pipeline_config.layer
118
+
119
+ return overview
120
+
121
+ def _generate_operation_details(self, validate: bool) -> List[Dict[str, Any]]:
122
+ """
123
+ Generate detailed operation explanations.
124
+
125
+ Args:
126
+ validate: Whether to validate explanation quality
127
+
128
+ Returns:
129
+ List of operation details with explanations
130
+ """
131
+ operations = []
132
+
133
+ for node in self.pipeline_config.nodes:
134
+ operation_detail = {
135
+ "node_name": node.name,
136
+ "operation_name": self._get_operation_name(node),
137
+ "params": {},
138
+ "explanation": self._get_node_description(node),
139
+ }
140
+
141
+ operations.append(operation_detail)
142
+
143
+ return operations
144
+
145
+ def _get_operation_name(self, node: NodeConfig) -> str:
146
+ """Get operation name from node config."""
147
+ if node.read:
148
+ return "read"
149
+ elif node.transform:
150
+ return (
151
+ f"transform ({node.transform.operation})"
152
+ if hasattr(node.transform, "operation")
153
+ else "transform"
154
+ )
155
+ elif node.write:
156
+ return "write"
157
+ return "unknown"
158
+
159
+ def _get_node_description(self, node: NodeConfig) -> str:
160
+ """Get description for node."""
161
+ if node.description:
162
+ return node.description
163
+
164
+ # Build basic description from operations
165
+ desc_parts = []
166
+ if node.read:
167
+ desc_parts.append(f"Reads from `{node.read.connection}`")
168
+ if node.transform:
169
+ if hasattr(node.transform, "operation"):
170
+ desc_parts.append(f"Transforms using `{node.transform.operation}`")
171
+ else:
172
+ desc_parts.append("Transforms data")
173
+ if node.write:
174
+ desc_parts.append(f"Writes to `{node.write.connection}`")
175
+
176
+ return " → ".join(desc_parts) if desc_parts else "No description available"
177
+
178
+ def _build_context(self, node: NodeConfig) -> Dict[str, Any]:
179
+ """
180
+ Build context dictionary for explanation.
181
+
182
+ Args:
183
+ node: Node configuration
184
+
185
+ Returns:
186
+ Context dictionary
187
+ """
188
+ context = {
189
+ "node": node.name,
190
+ "operation": self._get_operation_name(node),
191
+ "pipeline": self.pipeline_config.pipeline,
192
+ }
193
+
194
+ # Add project-level context if available
195
+ if self.project_config:
196
+ context["project"] = self.project_config.project
197
+ context["plant"] = getattr(self.project_config, "plant", None)
198
+ context["asset"] = getattr(self.project_config, "asset", None)
199
+ context["business_unit"] = getattr(self.project_config, "business_unit", None)
200
+
201
+ return context
202
+
203
+ def _generate_outputs(self) -> Dict[str, Any]:
204
+ """Generate expected outputs section."""
205
+ # Get final nodes (nodes with no dependents)
206
+ all_dependencies = set()
207
+ for node in self.pipeline_config.nodes:
208
+ if node.depends_on:
209
+ all_dependencies.update(node.depends_on)
210
+
211
+ final_nodes = [
212
+ node.name for node in self.pipeline_config.nodes if node.name not in all_dependencies
213
+ ]
214
+
215
+ return {
216
+ "final_nodes": final_nodes,
217
+ "description": f"This pipeline produces {len(final_nodes)} final output(s)",
218
+ }
219
+
220
+ def _generate_flow_diagram(self) -> str:
221
+ """
222
+ Generate ASCII flow diagram.
223
+
224
+ Returns:
225
+ ASCII art flow diagram
226
+ """
227
+ lines = []
228
+ lines.append("Pipeline Flow:")
229
+ lines.append("")
230
+
231
+ for i, node in enumerate(self.pipeline_config.nodes):
232
+ # Node representation
233
+ lines.append(f"{i + 1}. [{node.name}]")
234
+ lines.append(f" Operation: {self._get_operation_name(node)}")
235
+
236
+ if node.depends_on:
237
+ lines.append(f" Depends on: {', '.join(node.depends_on)}")
238
+
239
+ lines.append("")
240
+
241
+ return "\n".join(lines)
242
+
243
+ def _render_html(self, doc_data: Dict[str, Any], output_path: str, theme=None) -> str:
244
+ """
245
+ Render documentation as HTML.
246
+
247
+ Args:
248
+ doc_data: Documentation data
249
+ output_path: Output file path
250
+ theme: Optional StoryTheme for customization
251
+
252
+ Returns:
253
+ Path to generated file
254
+ """
255
+ try:
256
+ from jinja2 import Template
257
+ except ImportError:
258
+ raise ImportError(
259
+ "jinja2 is required for HTML rendering. Install with: pip install jinja2"
260
+ )
261
+
262
+ # HTML template for doc story
263
+ template_str = """
264
+ <!DOCTYPE html>
265
+ <html lang="en">
266
+ <head>
267
+ <meta charset="UTF-8">
268
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
269
+ <title>{{ doc.title }}</title>
270
+ <style>
271
+ body {
272
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
273
+ line-height: 1.6;
274
+ max-width: 1000px;
275
+ margin: 0 auto;
276
+ padding: 20px;
277
+ color: #333;
278
+ }
279
+ h1 { color: #0066cc; border-bottom: 3px solid #0066cc; padding-bottom: 10px; }
280
+ h2 { color: #0066cc; margin-top: 30px; }
281
+ h3 { color: #555; }
282
+ .overview { background: #f5f5f5; padding: 20px; border-radius: 8px; margin: 20px 0; }
283
+ .overview-item { margin: 10px 0; }
284
+ .operation-card {
285
+ border: 1px solid #ddd;
286
+ border-radius: 8px;
287
+ padding: 20px;
288
+ margin: 20px 0;
289
+ background: white;
290
+ }
291
+ .operation-header {
292
+ background: #0066cc;
293
+ color: white;
294
+ padding: 10px 15px;
295
+ border-radius: 4px;
296
+ margin: -20px -20px 15px -20px;
297
+ }
298
+ .params {
299
+ background: #f9f9f9;
300
+ padding: 10px;
301
+ border-left: 3px solid #0066cc;
302
+ margin: 10px 0;
303
+ }
304
+ .explanation { margin: 15px 0; }
305
+ pre {
306
+ background: #f5f5f5;
307
+ padding: 15px;
308
+ border-radius: 4px;
309
+ overflow-x: auto;
310
+ }
311
+ code {
312
+ background: #f5f5f5;
313
+ padding: 2px 6px;
314
+ border-radius: 3px;
315
+ }
316
+ .flow-diagram {
317
+ background: #f9f9f9;
318
+ padding: 20px;
319
+ border-radius: 8px;
320
+ font-family: monospace;
321
+ white-space: pre;
322
+ }
323
+ </style>
324
+ </head>
325
+ <body>
326
+ <!-- Nigerian accent bar -->
327
+ <div style="height: 4px; background: linear-gradient(to right, #008751 33%, #fff 33%, #fff 66%, #008751 66%); margin-bottom: 15px; border-radius: 2px;"></div>
328
+
329
+ <p style="color: #008751; font-size: 0.85em; margin: 0 0 5px 0; font-weight: 500;">Ndewo — Welcome to your data story</p>
330
+ <h1>{{ doc.title }}</h1>
331
+
332
+ <div class="overview">
333
+ <h2>Overview</h2>
334
+ <div class="overview-item"><strong>Pipeline:</strong> {{ doc.overview.pipeline_name }}</div>
335
+ <div class="overview-item"><strong>Description:</strong> {{ doc.overview.description }}</div>
336
+ <div class="overview-item"><strong>Total Operations:</strong> {{ doc.overview.total_nodes }}</div>
337
+
338
+ {% if doc.overview.project %}
339
+ <div class="overview-item"><strong>Project:</strong> {{ doc.overview.project }}</div>
340
+ {% endif %}
341
+
342
+ {% if doc.overview.plant %}
343
+ <div class="overview-item"><strong>Plant:</strong> {{ doc.overview.plant }}</div>
344
+ {% endif %}
345
+
346
+ {% if doc.overview.asset %}
347
+ <div class="overview-item"><strong>Asset:</strong> {{ doc.overview.asset }}</div>
348
+ {% endif %}
349
+
350
+ {% if doc.overview.layer %}
351
+ <div class="overview-item"><strong>Layer:</strong> {{ doc.overview.layer }}</div>
352
+ {% endif %}
353
+ </div>
354
+
355
+ {% if doc.flow_diagram %}
356
+ <h2>Pipeline Flow</h2>
357
+ <div class="flow-diagram">{{ doc.flow_diagram }}</div>
358
+ {% endif %}
359
+
360
+ <h2>Operations</h2>
361
+
362
+ {% for op in doc.operations %}
363
+ <div class="operation-card">
364
+ <div class="operation-header">
365
+ <h3 style="margin: 0; color: white;">{{ op.node_name }}</h3>
366
+ <div style="opacity: 0.9; font-size: 14px;">Operation: <code style="background: rgba(255,255,255,0.2);">{{ op.operation_name }}</code></div>
367
+ </div>
368
+
369
+ {% if op.params %}
370
+ <div class="params">
371
+ <strong>Parameters:</strong>
372
+ <ul>
373
+ {% for key, value in op.params.items() %}
374
+ <li><code>{{ key }}</code>: {{ value }}</li>
375
+ {% endfor %}
376
+ </ul>
377
+ </div>
378
+ {% endif %}
379
+
380
+ <div class="explanation">
381
+ {% if op.explanation %}
382
+ {{ op.explanation|safe }}
383
+ {% elif op.explanation_error %}
384
+ <p style="color: #dc3545;"><strong>Error generating explanation:</strong> {{ op.explanation_error }}</p>
385
+ {% else %}
386
+ <p style="color: #666;"><em>No explanation available</em></p>
387
+ {% endif %}
388
+ </div>
389
+ </div>
390
+ {% endfor %}
391
+
392
+ <h2>Expected Outputs</h2>
393
+ <p>{{ doc.expected_outputs.description }}</p>
394
+ <ul>
395
+ {% for node in doc.expected_outputs.final_nodes %}
396
+ <li><strong>{{ node }}</strong></li>
397
+ {% endfor %}
398
+ </ul>
399
+
400
+ <hr style="margin-top: 40px; border: none; border-top: 1px solid #ddd;">
401
+ <p style="text-align: center; color: #666; font-size: 14px; font-style: italic;">
402
+ "Where others saw gaps, I built bridges."
403
+ </p>
404
+ <p style="text-align: center; color: #888; font-size: 12px;">
405
+ Odibi v{{ odibi_version }} · Henry Odibi
406
+ <svg style="vertical-align: middle; margin-left: 4px;" width="20" height="14" viewBox="0 0 3 2">
407
+ <rect width="1" height="2" x="0" fill="#008751"/>
408
+ <rect width="1" height="2" x="1" fill="#ffffff"/>
409
+ <rect width="1" height="2" x="2" fill="#008751"/>
410
+ </svg>
411
+ </p>
412
+ </body>
413
+ </html>
414
+ """
415
+
416
+ # Apply theme if provided
417
+ if theme:
418
+ theme_css = theme.to_css_string()
419
+ # Replace default :root styles with theme
420
+ template_str = template_str.replace(
421
+ "body {\n font-family: -apple-system",
422
+ f"{theme_css}\n body {{\n font-family: var(--font-family)",
423
+ )
424
+
425
+ template = Template(template_str)
426
+ html = template.render(doc=doc_data, theme=theme, odibi_version=odibi.__version__)
427
+
428
+ output_file = Path(output_path)
429
+ output_file.parent.mkdir(parents=True, exist_ok=True)
430
+
431
+ with open(output_file, "w", encoding="utf-8") as f:
432
+ f.write(html)
433
+
434
+ return str(output_file)
435
+
436
+ def _render_markdown(self, doc_data: Dict[str, Any], output_path: str) -> str:
437
+ """
438
+ Render documentation as Markdown.
439
+
440
+ Args:
441
+ doc_data: Documentation data
442
+ output_path: Output file path
443
+
444
+ Returns:
445
+ Path to generated file
446
+ """
447
+ lines = []
448
+
449
+ # Title
450
+ lines.append(f"# {doc_data['title']}")
451
+ lines.append("")
452
+
453
+ # Overview
454
+ lines.append("## Overview")
455
+ lines.append("")
456
+ overview = doc_data["overview"]
457
+ lines.append(f"**Pipeline:** {overview['pipeline_name']}")
458
+ lines.append(f"**Description:** {overview['description']}")
459
+ lines.append(f"**Total Operations:** {overview['total_nodes']}")
460
+
461
+ if overview.get("project"):
462
+ lines.append(f"**Project:** {overview['project']}")
463
+ if overview.get("plant"):
464
+ lines.append(f"**Plant:** {overview['plant']}")
465
+ if overview.get("asset"):
466
+ lines.append(f"**Asset:** {overview['asset']}")
467
+ if overview.get("layer"):
468
+ lines.append(f"**Layer:** {overview['layer']}")
469
+
470
+ lines.append("")
471
+ lines.append("---")
472
+ lines.append("")
473
+
474
+ # Flow diagram
475
+ if "flow_diagram" in doc_data:
476
+ lines.append("## Pipeline Flow")
477
+ lines.append("")
478
+ lines.append("```")
479
+ lines.append(doc_data["flow_diagram"])
480
+ lines.append("```")
481
+ lines.append("")
482
+
483
+ # Operations
484
+ lines.append("## Operations")
485
+ lines.append("")
486
+
487
+ for op in doc_data["operations"]:
488
+ lines.append(f"### {op['node_name']}")
489
+ lines.append("")
490
+ lines.append(f"**Operation:** `{op['operation_name']}`")
491
+ lines.append("")
492
+
493
+ if op["params"]:
494
+ lines.append("**Parameters:**")
495
+ for key, value in op["params"].items():
496
+ lines.append(f"- `{key}`: {value}")
497
+ lines.append("")
498
+
499
+ if op.get("explanation"):
500
+ lines.append(op["explanation"])
501
+ elif op.get("explanation_error"):
502
+ lines.append(f"*Error generating explanation: {op['explanation_error']}*")
503
+ else:
504
+ lines.append("*No explanation available*")
505
+
506
+ lines.append("")
507
+ lines.append("---")
508
+ lines.append("")
509
+
510
+ # Expected outputs
511
+ lines.append("## Expected Outputs")
512
+ lines.append("")
513
+ lines.append(doc_data["expected_outputs"]["description"])
514
+ lines.append("")
515
+ for node in doc_data["expected_outputs"]["final_nodes"]:
516
+ lines.append(f"- **{node}**")
517
+
518
+ lines.append("")
519
+ lines.append("---")
520
+ lines.append("")
521
+ lines.append('*"Where others saw gaps, I built bridges."*')
522
+ lines.append("")
523
+ lines.append(f"Odibi v{odibi.__version__} · Henry Odibi 🌍")
524
+
525
+ markdown = "\n".join(lines)
526
+
527
+ output_file = Path(output_path)
528
+ output_file.parent.mkdir(parents=True, exist_ok=True)
529
+
530
+ with open(output_file, "w", encoding="utf-8") as f:
531
+ f.write(markdown)
532
+
533
+ return str(output_file)
534
+
535
+ def _render_json(self, doc_data: Dict[str, Any], output_path: str) -> str:
536
+ """
537
+ Render documentation as JSON.
538
+
539
+ Args:
540
+ doc_data: Documentation data
541
+ output_path: Output file path
542
+
543
+ Returns:
544
+ Path to generated file
545
+ """
546
+ import json
547
+
548
+ output_file = Path(output_path)
549
+ output_file.parent.mkdir(parents=True, exist_ok=True)
550
+
551
+ with open(output_file, "w", encoding="utf-8") as f:
552
+ json.dump(doc_data, f, indent=2)
553
+
554
+ return str(output_file)