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/__init__.py
ADDED
|
@@ -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"
|
odibi/story/doc_story.py
ADDED
|
@@ -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)
|