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
« 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.
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.
8Requires the optional `web` extra:
10 pip install pyairflowtester[web]
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"""
17from __future__ import annotations
19import html as _html
20from pathlib import Path
21from typing import Any, List, Optional
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
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
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# --------------------------------------------------------------------------
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>'
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>"
85 if value is None or value == "":
86 return '<span class="muted">—</span>'
88 return _html.escape(str(value))
91# --------------------------------------------------------------------------
92# Templates (kept inline as plain strings so the package needs no extra
93# packaged data files / MANIFEST entries).
94# --------------------------------------------------------------------------
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"""
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 — 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 "—" | 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 }} — PyAirflowTester Dashboard{% endblock %}
205{% block content %}
206<p><a href="/">← 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 — PyAirflowTester Dashboard{% endblock %}
214{% block content %}
215<p><a href="/">← All nodes</a></p>
216<h1>System Health</h1>
217{{ content }}
218{% endblock %}
219""",
220}
222_env = Environment(
223 loader=DictLoader(_TEMPLATES),
224 autoescape=select_autoescape(["html"]),
225)
228# --------------------------------------------------------------------------
229# App factory
230# --------------------------------------------------------------------------
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`.
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)
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
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 )
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)
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)
284 return app
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.
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")]
301 graph = UnifiedGraphBuilder.build_unified_graph(
302 dag_files=dag_files,
303 dbt_manifest=dbt_manifest,
304 )
305 return create_app(graph)