Coverage for python/pyairflowtester/web/app.py: 93%

72 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-19 20:43 +0530

1"""FastAPI web dashboard that serves DashboardBuilder output as real HTML. 

2 

3This is the browsable counterpart to `pyairflowtester dependency ...`: it builds 

4the same unified dependency graph (from Airflow DAG files and/or a dbt 

5`manifest.json`) and renders `DashboardBuilder.build_node_dashboard()` / 

6`build_health_dashboard()` output as readable HTML pages instead of raw dicts. 

7 

8Requires the optional `web` extra: 

9 

10 pip install pyairflowtester[web] 

11 

12Nothing else in the package imports this module, so a plain `pip install 

13pyairflowtester` (no extras) is unaffected by the fastapi/uvicorn/jinja2 

14dependency. 

15""" 

16 

17from __future__ import annotations 

18 

19import html as _html 

20from pathlib import Path 

21from typing import Any, List, Optional 

22 

23try: 

24 from fastapi import FastAPI, HTTPException 

25 from fastapi.responses import HTMLResponse 

26 from jinja2 import DictLoader, Environment, select_autoescape 

27 from markupsafe import Markup 

28except ImportError as exc: # pragma: no cover - exercised only without the extra 

29 raise ImportError( 

30 "The PyAirflowTester web dashboard requires optional dependencies " 

31 "(fastapi, uvicorn, jinja2). Install them with:\n\n" 

32 " pip install pyairflowtester[web]\n" 

33 ) from exc 

34 

35from pyairflowtester.dependency_intelligence.models import DependencyGraph 

36from pyairflowtester.dependency_intelligence.observability import ( 

37 AlertManager, 

38 DashboardBuilder, 

39 EventLogger, 

40 MetricsCollector, 

41) 

42from pyairflowtester.dependency_intelligence.parsers import UnifiedGraphBuilder 

43 

44# -------------------------------------------------------------------------- 

45# Rendering helpers: turn arbitrary dict/list output from DashboardBuilder 

46# into real HTML tables/lists, not a JSON dump styled with CSS. 

47# -------------------------------------------------------------------------- 

48 

49 

50def _render_value(value: Any) -> str: 

51 """Recursively render a Python value (from a DashboardBuilder dict) as HTML.""" 

52 if isinstance(value, dict): 

53 if not value: 53 ↛ 54line 53 didn't jump to line 54 because the condition on line 53 was never true

54 return '<p class="muted">(empty)</p>' 

55 rows = "".join( 

56 f"<tr><th>{_html.escape(str(k))}</th><td>{_render_value(v)}</td></tr>" 

57 for k, v in value.items() 

58 ) 

59 return f'<table class="kv">{rows}</table>' 

60 

61 if isinstance(value, (list, tuple)): 

62 if not value: 

63 return '<p class="muted">(none)</p>' 

64 if all(isinstance(item, dict) for item in value): 64 ↛ 82line 64 didn't jump to line 82 because the condition on line 64 was always true

65 # Union of keys across all rows, in first-seen order. 

66 columns: List[str] = [] 

67 for item in value: 

68 for k in item: 

69 if k not in columns: 

70 columns.append(k) 

71 header = "".join(f"<th>{_html.escape(c)}</th>" for c in columns) 

72 body = "".join( 

73 "<tr>" 

74 + "".join(f"<td>{_render_value(item.get(c, ''))}</td>" for c in columns) 

75 + "</tr>" 

76 for item in value 

77 ) 

78 return ( 

79 f'<table class="list-table"><thead><tr>{header}</tr></thead>' 

80 f"<tbody>{body}</tbody></table>" 

81 ) 

82 items = "".join(f"<li>{_render_value(item)}</li>" for item in value) 

83 return f"<ul>{items}</ul>" 

84 

85 if value is None or value == "": 

86 return '<span class="muted">&mdash;</span>' 

87 

88 return _html.escape(str(value)) 

89 

90 

91# -------------------------------------------------------------------------- 

92# Templates (kept inline as plain strings so the package needs no extra 

93# packaged data files / MANIFEST entries). 

94# -------------------------------------------------------------------------- 

95 

96_BASE_CSS = """ 

97:root { color-scheme: light dark; } 

98body { 

99 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; 

100 margin: 0; padding: 0; 

101 background: #f6f7f9; color: #1a1a1a; 

102} 

103header { 

104 background: #1d2333; color: #fff; padding: 1rem 1.5rem; 

105 display: flex; align-items: baseline; gap: 1rem; 

106} 

107header a { color: #cfe0ff; text-decoration: none; font-weight: 600; } 

108header a:hover { text-decoration: underline; } 

109header .tagline { color: #9aa4c0; font-size: 0.85rem; } 

110main { max-width: 1000px; margin: 1.5rem auto; padding: 0 1.5rem 3rem; } 

111h1 { font-size: 1.4rem; margin-top: 0; } 

112h2 { font-size: 1.05rem; margin-top: 2rem; border-bottom: 1px solid #ddd; padding-bottom: 0.3rem; } 

113table { border-collapse: collapse; width: 100%; margin: 0.5rem 0 1rem; background: #fff; } 

114table.kv th, table.kv td, table.list-table th, table.list-table td, 

115table.index th, table.index td { 

116 border: 1px solid #e0e2e7; padding: 0.4rem 0.6rem; text-align: left; vertical-align: top; 

117 font-size: 0.92rem; 

118} 

119table.kv th { width: 220px; background: #f0f2f6; font-weight: 600; } 

120table.list-table thead th, table.index thead th { background: #f0f2f6; font-weight: 600; } 

121a.node-link { color: #2454c7; text-decoration: none; font-weight: 600; } 

122a.node-link:hover { text-decoration: underline; } 

123.muted { color: #888; } 

124.badge { 

125 display: inline-block; padding: 0.1rem 0.5rem; border-radius: 3px; 

126 font-size: 0.78rem; font-weight: 600; color: #fff; 

127} 

128.badge.critical { background: #c0392b; } 

129.badge.high { background: #d35400; } 

130.badge.medium { background: #b8860b; } 

131.badge.low { background: #2e7d32; } 

132.stats { display: flex; gap: 1.5rem; margin: 1rem 0; flex-wrap: wrap; } 

133.stat { background: #fff; border: 1px solid #e0e2e7; border-radius: 6px; padding: 0.75rem 1.25rem; } 

134.stat .n { font-size: 1.6rem; font-weight: 700; display: block; } 

135.stat .l { font-size: 0.78rem; color: #666; text-transform: uppercase; letter-spacing: 0.03em; } 

136@media (prefers-color-scheme: dark) { 

137 body { background: #16181d; color: #e6e6e6; } 

138 header { background: #10131c; } 

139 table, .stat { background: #1e2129; } 

140 table.kv th, table.kv td, table.list-table th, table.list-table td, 

141 table.index th, table.index td { border-color: #2c303a; } 

142 table.kv th, table.list-table thead th, table.index thead th { background: #262a34; } 

143 h2 { border-bottom-color: #2c303a; } 

144 a.node-link { color: #7aa2f7; } 

145} 

146""" 

147 

148_TEMPLATES = { 

149 "base.html": """<!doctype html> 

150<html lang="en"> 

151<head> 

152<meta charset="utf-8"> 

153<meta name="viewport" content="width=device-width, initial-scale=1"> 

154<title>{% block title %}PyAirflowTester Dashboard{% endblock %}</title> 

155<style>""" 

156 + _BASE_CSS 

157 + """</style> 

158</head> 

159<body> 

160<header> 

161 <a href="/">PyAirflowTester</a> 

162 <span class="tagline">dependency intelligence dashboard</span> 

163 <span style="flex:1"></span> 

164 <a href="/">Nodes</a> 

165 <a href="/health">System Health</a> 

166</header> 

167<main> 

168{% block content %}{% endblock %} 

169</main> 

170</body> 

171</html> 

172""", 

173 "index.html": """{% extends "base.html" %} 

174{% block title %}Nodes &mdash; PyAirflowTester Dashboard{% endblock %} 

175{% block content %} 

176<h1>Dependency Graph</h1> 

177<div class="stats"> 

178 <div class="stat"><span class="n">{{ node_count }}</span><span class="l">Nodes</span></div> 

179 <div class="stat"><span class="n">{{ edge_count }}</span><span class="l">Edges</span></div> 

180</div> 

181{% if nodes %} 

182<table class="index"> 

183<thead><tr><th>Name</th><th>Type</th><th>Severity</th><th>Owner</th><th>Upstream</th><th>Downstream</th></tr></thead> 

184<tbody> 

185{% for node in nodes %} 

186<tr> 

187 <td><a class="node-link" href="/nodes/{{ node.id }}">{{ node.name }}</a></td> 

188 <td>{{ node.type.value }}</td> 

189 <td><span class="badge {{ node.severity.value }}">{{ node.severity.value }}</span></td> 

190 <td>{{ node.owner or "&mdash;" | safe }}</td> 

191 <td>{{ node.upstream_count }}</td> 

192 <td>{{ node.downstream_count }}</td> 

193</tr> 

194{% endfor %} 

195</tbody> 

196</table> 

197{% else %} 

198<p class="muted">No nodes in the graph. Pass --dags and/or --dbt-manifest when running 

199<code>pyairflowtester serve</code> to build one.</p> 

200{% endif %} 

201{% endblock %} 

202""", 

203 "node.html": """{% extends "base.html" %} 

204{% block title %}{{ node_id }} &mdash; PyAirflowTester Dashboard{% endblock %} 

205{% block content %} 

206<p><a href="/">&larr; All nodes</a></p> 

207<h1>{{ dashboard.node_name }}</h1> 

208<p class="muted">{{ node_id }}</p> 

209{{ content }} 

210{% endblock %} 

211""", 

212 "health.html": """{% extends "base.html" %} 

213{% block title %}System Health &mdash; PyAirflowTester Dashboard{% endblock %} 

214{% block content %} 

215<p><a href="/">&larr; All nodes</a></p> 

216<h1>System Health</h1> 

217{{ content }} 

218{% endblock %} 

219""", 

220} 

221 

222_env = Environment( 

223 loader=DictLoader(_TEMPLATES), 

224 autoescape=select_autoescape(["html"]), 

225) 

226 

227 

228# -------------------------------------------------------------------------- 

229# App factory 

230# -------------------------------------------------------------------------- 

231 

232 

233def create_app( 

234 graph: DependencyGraph, 

235 metrics_collector: Optional[MetricsCollector] = None, 

236 alert_manager: Optional[AlertManager] = None, 

237 event_logger: Optional[EventLogger] = None, 

238) -> "FastAPI": 

239 """Build a FastAPI app that serves DashboardBuilder output for `graph`. 

240 

241 Reuses the existing `DashboardBuilder` (python/pyairflowtester/ 

242 dependency_intelligence/observability.py) rather than reimplementing any 

243 dashboard logic here -- this module is purely a rendering layer. 

244 """ 

245 metrics_collector = metrics_collector or MetricsCollector() 

246 alert_manager = alert_manager or AlertManager(graph) 

247 event_logger = event_logger or EventLogger(graph) 

248 builder = DashboardBuilder(graph, metrics_collector, alert_manager, event_logger) 

249 

250 app = FastAPI( 

251 title="PyAirflowTester Dashboard", 

252 description="Browsable dependency-intelligence dashboard for PyAirflowTester.", 

253 ) 

254 app.state.graph = graph 

255 app.state.builder = builder 

256 

257 @app.get("/", response_class=HTMLResponse) 

258 def list_nodes() -> str: 

259 """List all DAGs/tasks/models/etc. currently in the dependency graph.""" 

260 nodes = sorted(graph.nodes.values(), key=lambda n: (n.type.value, n.name)) 

261 template = _env.get_template("index.html") 

262 return template.render( 

263 nodes=nodes, node_count=len(graph.nodes), edge_count=len(graph.edges) 

264 ) 

265 

266 @app.get("/nodes/{node_id}", response_class=HTMLResponse) 

267 def node_dashboard(node_id: str) -> str: 

268 """Render DashboardBuilder.build_node_dashboard(node_id) as HTML.""" 

269 if node_id not in graph.nodes: 

270 raise HTTPException(status_code=404, detail=f"Unknown node: {node_id!r}") 

271 dashboard = builder.build_node_dashboard(node_id) 

272 content = Markup(_render_value(dashboard)) 

273 template = _env.get_template("node.html") 

274 return template.render(node_id=node_id, dashboard=dashboard, content=content) 

275 

276 @app.get("/health", response_class=HTMLResponse) 

277 def health_dashboard() -> str: 

278 """Render DashboardBuilder.build_health_dashboard() as HTML.""" 

279 dashboard = builder.build_health_dashboard() 

280 content = Markup(_render_value(dashboard)) 

281 template = _env.get_template("health.html") 

282 return template.render(content=content) 

283 

284 return app 

285 

286 

287def build_app_from_sources( 

288 dags: Optional[str] = None, 

289 dbt_manifest: Optional[str] = None, 

290) -> "FastAPI": 

291 """Build the dependency graph from DAG files / a dbt manifest, then serve it. 

292 

293 Mirrors the source-collection logic used by `pyairflowtester dependency 

294 build/impact/lineage/...` (see dependency_intelligence/cli.py) so `serve` 

295 accepts the same --dags/--dbt-manifest options as the rest of the CLI. 

296 """ 

297 dag_files: List[str] = [] 

298 if dags: 298 ↛ 299line 298 didn't jump to line 299 because the condition on line 298 was never true

299 dag_files = [str(p) for p in Path(dags).glob("**/*.py")] 

300 

301 graph = UnifiedGraphBuilder.build_unified_graph( 

302 dag_files=dag_files, 

303 dbt_manifest=dbt_manifest, 

304 ) 

305 return create_app(graph)