flowsense-engine 0.2.1__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 (49) hide show
  1. flowsense/__init__.py +77 -0
  2. flowsense/application/__init__.py +20 -0
  3. flowsense/application/analyzer.py +68 -0
  4. flowsense/application/output.py +128 -0
  5. flowsense/application/pipeline.py +210 -0
  6. flowsense/application/ports.py +11 -0
  7. flowsense/application/request.py +32 -0
  8. flowsense/application/serialization.py +177 -0
  9. flowsense/cli/__init__.py +0 -0
  10. flowsense/cli/main.py +195 -0
  11. flowsense/cli/report.py +233 -0
  12. flowsense/collector/__init__.py +0 -0
  13. flowsense/collector/airflow_client.py +5 -0
  14. flowsense/config.py +72 -0
  15. flowsense/domain/__init__.py +52 -0
  16. flowsense/domain/enums.py +47 -0
  17. flowsense/domain/exceptions.py +25 -0
  18. flowsense/domain/models.py +19 -0
  19. flowsense/domain/policy.py +55 -0
  20. flowsense/domain/results.py +187 -0
  21. flowsense/engine/__init__.py +0 -0
  22. flowsense/engine/analyzer.py +17 -0
  23. flowsense/engine/change_point.py +87 -0
  24. flowsense/engine/drift.py +80 -0
  25. flowsense/engine/history.py +34 -0
  26. flowsense/engine/impact.py +51 -0
  27. flowsense/engine/propagation.py +173 -0
  28. flowsense/engine/root_cause.py +148 -0
  29. flowsense/engine/timing.py +221 -0
  30. flowsense/engine/trend.py +82 -0
  31. flowsense/infrastructure/__init__.py +1 -0
  32. flowsense/infrastructure/airflow/__init__.py +13 -0
  33. flowsense/infrastructure/airflow/client.py +373 -0
  34. flowsense/infrastructure/airflow/dto.py +33 -0
  35. flowsense/infrastructure/airflow/exceptions.py +31 -0
  36. flowsense/infrastructure/airflow/mapper.py +27 -0
  37. flowsense/mcp/__init__.py +0 -0
  38. flowsense/mcp/server.py +75 -0
  39. flowsense/models/__init__.py +7 -0
  40. flowsense/models/dag_analysis.py +3 -0
  41. flowsense/models/task_run.py +3 -0
  42. flowsense/py.typed +0 -0
  43. flowsense/version.py +6 -0
  44. flowsense_engine-0.2.1.dist-info/METADATA +447 -0
  45. flowsense_engine-0.2.1.dist-info/RECORD +49 -0
  46. flowsense_engine-0.2.1.dist-info/WHEEL +5 -0
  47. flowsense_engine-0.2.1.dist-info/entry_points.txt +3 -0
  48. flowsense_engine-0.2.1.dist-info/licenses/LICENSE +17 -0
  49. flowsense_engine-0.2.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,177 @@
1
+ from __future__ import annotations
2
+
3
+ from flowsense.application.output import (
4
+ AnalysisDocument,
5
+ AnalysisPolicyOutput,
6
+ AnalysisSummaryOutput,
7
+ ChangePointOutput,
8
+ DiagnosticOutput,
9
+ DriftOutput,
10
+ PropagationOutput,
11
+ RootCauseOutput,
12
+ TaskImpactOutput,
13
+ TrendOutput,
14
+ )
15
+ from flowsense.domain import DAGAnalysis, DriftResult
16
+
17
+
18
+ def _drift_output(result: DriftResult) -> DriftOutput:
19
+ return DriftOutput(
20
+ baseline=result.baseline,
21
+ current=result.current,
22
+ mad=result.mad,
23
+ robust_z_score=result.robust_z_score,
24
+ deviation_percent=result.deviation_percent,
25
+ severity=result.severity,
26
+ )
27
+
28
+
29
+ def build_analysis_document(analysis: DAGAnalysis) -> AnalysisDocument:
30
+ """Build and validate the typed public output document."""
31
+ summary = analysis.summary
32
+
33
+ return AnalysisDocument(
34
+ dag_id=analysis.dag_id,
35
+ current_dag_run_id=analysis.current_dag_run_id,
36
+ runs_analyzed=analysis.runs_analyzed,
37
+ overall_severity=analysis.overall_severity,
38
+ summary=AnalysisSummaryOutput(
39
+ total_tasks=summary.total_tasks,
40
+ analyzed_tasks=summary.analyzed_tasks,
41
+ analysis_coverage_percent=summary.analysis_coverage_percent,
42
+ normal_tasks=summary.normal_tasks,
43
+ medium_tasks=summary.medium_tasks,
44
+ high_tasks=summary.high_tasks,
45
+ critical_tasks=summary.critical_tasks,
46
+ anomalous_tasks=summary.anomalous_tasks,
47
+ anomalous_handoffs=summary.anomalous_handoffs,
48
+ affected_tasks=summary.affected_tasks,
49
+ change_points=summary.change_points,
50
+ trends=summary.trends,
51
+ diagnostics=summary.diagnostics,
52
+ ),
53
+ policy=AnalysisPolicyOutput(
54
+ minimum_history=analysis.policy.minimum_history,
55
+ baseline_window=analysis.policy.baseline_window,
56
+ medium_threshold=analysis.policy.medium_threshold,
57
+ high_threshold=analysis.policy.high_threshold,
58
+ critical_threshold=analysis.policy.critical_threshold,
59
+ mapped_task_aggregation=analysis.policy.mapped_task_aggregation,
60
+ change_point_detection_enabled=(
61
+ analysis.policy.change_point_detection_enabled
62
+ ),
63
+ change_point_minimum_segment_size=(
64
+ analysis.policy.change_point_minimum_segment_size
65
+ ),
66
+ change_point_score_threshold=(analysis.policy.change_point_score_threshold),
67
+ trend_detection_enabled=analysis.policy.trend_detection_enabled,
68
+ trend_minimum_observations=analysis.policy.trend_minimum_observations,
69
+ trend_score_threshold=analysis.policy.trend_score_threshold,
70
+ trend_minimum_directional_consistency=(
71
+ analysis.policy.trend_minimum_directional_consistency
72
+ ),
73
+ ),
74
+ primary_origin=(
75
+ RootCauseOutput(
76
+ task_id=analysis.primary_origin.task_id,
77
+ classification=analysis.primary_origin.classification,
78
+ severity=analysis.primary_origin.severity,
79
+ propagation_score=analysis.primary_origin.propagation_score,
80
+ )
81
+ if analysis.primary_origin
82
+ else None
83
+ ),
84
+ drift_results={
85
+ task_id: _drift_output(result)
86
+ for task_id, result in analysis.drift_results.items()
87
+ },
88
+ change_point_results={
89
+ task_id: ChangePointOutput(
90
+ change_index=result.change_index,
91
+ before_median=result.before_median,
92
+ after_median=result.after_median,
93
+ change_percent=result.change_percent,
94
+ score=result.score,
95
+ direction=result.direction,
96
+ )
97
+ for task_id, result in analysis.change_point_results.items()
98
+ },
99
+ trend_results={
100
+ task_id: TrendOutput(
101
+ direction=result.direction,
102
+ slope_per_observation=result.slope_per_observation,
103
+ estimated_change=result.estimated_change,
104
+ change_percent=result.change_percent,
105
+ score=result.score,
106
+ directional_consistency=result.directional_consistency,
107
+ observations=result.observations,
108
+ )
109
+ for task_id, result in analysis.trend_results.items()
110
+ },
111
+ handoff_drift_results={
112
+ f"{upstream}->{downstream}": _drift_output(result)
113
+ for (upstream, downstream), result in analysis.handoff_drift_results.items()
114
+ },
115
+ handoff_change_point_results={
116
+ f"{upstream}->{downstream}": ChangePointOutput(
117
+ change_index=result.change_index,
118
+ before_median=result.before_median,
119
+ after_median=result.after_median,
120
+ change_percent=result.change_percent,
121
+ score=result.score,
122
+ direction=result.direction,
123
+ )
124
+ for (
125
+ upstream,
126
+ downstream,
127
+ ), result in analysis.handoff_change_point_results.items()
128
+ },
129
+ handoff_trend_results={
130
+ f"{upstream}->{downstream}": TrendOutput(
131
+ direction=result.direction,
132
+ slope_per_observation=result.slope_per_observation,
133
+ estimated_change=result.estimated_change,
134
+ change_percent=result.change_percent,
135
+ score=result.score,
136
+ directional_consistency=result.directional_consistency,
137
+ observations=result.observations,
138
+ )
139
+ for (upstream, downstream), result in analysis.handoff_trend_results.items()
140
+ },
141
+ task_impacts={
142
+ task_id: TaskImpactOutput(
143
+ classification=impact.classification,
144
+ task_severity=impact.task_severity,
145
+ upstream_handoff_severity=impact.upstream_handoff_severity,
146
+ )
147
+ for task_id, impact in analysis.task_impacts.items()
148
+ },
149
+ propagation_results=[
150
+ PropagationOutput(
151
+ origin_task=result.origin_task,
152
+ affected_tasks=result.affected_tasks,
153
+ path=result.path,
154
+ propagation_score=result.propagation_score,
155
+ )
156
+ for result in analysis.propagation_results
157
+ ],
158
+ dependencies=analysis.dependencies,
159
+ diagnostics=[
160
+ DiagnosticOutput(
161
+ code=diagnostic.code,
162
+ subject_id=diagnostic.subject_id,
163
+ message=diagnostic.message,
164
+ )
165
+ for diagnostic in analysis.diagnostics
166
+ ],
167
+ )
168
+
169
+
170
+ def serialize_analysis(analysis: DAGAnalysis) -> dict[str, object]:
171
+ """Serialize a DAG analysis to the versioned public output schema."""
172
+ return build_analysis_document(analysis).model_dump(mode="json")
173
+
174
+
175
+ def analysis_json_schema() -> dict[str, object]:
176
+ """Return the JSON Schema for the current analysis output contract."""
177
+ return AnalysisDocument.model_json_schema()
File without changes
flowsense/cli/main.py ADDED
@@ -0,0 +1,195 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from enum import StrEnum
5
+ from typing import Annotated
6
+
7
+ import typer
8
+ from rich.console import Console
9
+
10
+ from flowsense.application import (
11
+ AnalysisRequest,
12
+ analysis_json_schema,
13
+ analyze_dag,
14
+ serialize_analysis,
15
+ )
16
+ from flowsense.cli.report import render_analysis
17
+ from flowsense.domain import (
18
+ AnalysisPolicy,
19
+ FlowSenseError,
20
+ MappedTaskAggregation,
21
+ Severity,
22
+ severity_meets_threshold,
23
+ )
24
+ from flowsense.infrastructure.airflow import AirflowApiError, AirflowClient
25
+ from flowsense.version import __version__
26
+
27
+ app = typer.Typer(
28
+ name="flowsense",
29
+ help="Temporal drift and anomaly detection for Apache Airflow.",
30
+ no_args_is_help=True,
31
+ )
32
+
33
+ console = Console()
34
+ ANALYSIS_THRESHOLD_EXIT_CODE = 2
35
+
36
+
37
+ def _version_callback(value: bool) -> None:
38
+ if value:
39
+ typer.echo(__version__)
40
+ raise typer.Exit()
41
+
42
+
43
+ class OutputFormat(StrEnum):
44
+ TABLE = "table"
45
+ JSON = "json"
46
+
47
+
48
+ class FailureThreshold(StrEnum):
49
+ MEDIUM = "medium"
50
+ HIGH = "high"
51
+ CRITICAL = "critical"
52
+
53
+
54
+ @app.callback()
55
+ def main(
56
+ version: Annotated[
57
+ bool | None,
58
+ typer.Option(
59
+ "--version",
60
+ callback=_version_callback,
61
+ is_eager=True,
62
+ help="Show the installed FlowSense version and exit.",
63
+ ),
64
+ ] = None,
65
+ ) -> None:
66
+ """FlowSense CLI."""
67
+
68
+
69
+ @app.command("schema")
70
+ def show_schema() -> None:
71
+ """Print the versioned analysis output JSON Schema."""
72
+ typer.echo(
73
+ json.dumps(
74
+ analysis_json_schema(),
75
+ indent=2,
76
+ ensure_ascii=False,
77
+ sort_keys=True,
78
+ )
79
+ )
80
+
81
+
82
+ @app.command()
83
+ def analyze(
84
+ dag_id: str = typer.Argument(
85
+ ...,
86
+ help="Airflow DAG id to analyze.",
87
+ ),
88
+ minimum_history: int = typer.Option(5, min=2),
89
+ baseline_window: int | None = typer.Option(None, min=1),
90
+ medium_threshold: float = typer.Option(2.0, min=0.0),
91
+ high_threshold: float = typer.Option(3.5, min=0.0),
92
+ critical_threshold: float = typer.Option(5.0, min=0.0),
93
+ change_point_detection: bool = typer.Option(
94
+ True,
95
+ "--change-point-detection/--no-change-point-detection",
96
+ ),
97
+ change_point_minimum_segment_size: int = typer.Option(3, min=2),
98
+ change_point_score_threshold: float = typer.Option(3.5, min=0.0),
99
+ trend_detection: bool = typer.Option(
100
+ True,
101
+ "--trend-detection/--no-trend-detection",
102
+ ),
103
+ trend_minimum_observations: int = typer.Option(5, min=3),
104
+ trend_score_threshold: float = typer.Option(3.5, min=0.0),
105
+ trend_minimum_directional_consistency: float = typer.Option(
106
+ 0.6,
107
+ min=0.0,
108
+ max=1.0,
109
+ ),
110
+ mapped_task_aggregation: Annotated[
111
+ MappedTaskAggregation,
112
+ typer.Option(),
113
+ ] = MappedTaskAggregation.MAX,
114
+ output: Annotated[
115
+ OutputFormat,
116
+ typer.Option("--output", "-o"),
117
+ ] = OutputFormat.TABLE,
118
+ fail_on: Annotated[
119
+ FailureThreshold | None,
120
+ typer.Option(
121
+ "--fail-on",
122
+ help="Exit with code 2 when severity reaches this threshold.",
123
+ ),
124
+ ] = None,
125
+ history_run_limit: int | None = typer.Option(
126
+ None,
127
+ min=2,
128
+ help="Limit collection to the most recent successful DAG runs.",
129
+ ),
130
+ dag_run_id: str | None = typer.Option(
131
+ None,
132
+ "--dag-run-id",
133
+ help="Analyze this successful DAG run using only its preceding history.",
134
+ ),
135
+ ) -> None:
136
+ try:
137
+ policy = AnalysisPolicy(
138
+ minimum_history=minimum_history,
139
+ baseline_window=baseline_window,
140
+ medium_threshold=medium_threshold,
141
+ high_threshold=high_threshold,
142
+ critical_threshold=critical_threshold,
143
+ mapped_task_aggregation=mapped_task_aggregation,
144
+ change_point_detection_enabled=change_point_detection,
145
+ change_point_minimum_segment_size=change_point_minimum_segment_size,
146
+ change_point_score_threshold=change_point_score_threshold,
147
+ trend_detection_enabled=trend_detection,
148
+ trend_minimum_observations=trend_minimum_observations,
149
+ trend_score_threshold=trend_score_threshold,
150
+ trend_minimum_directional_consistency=(
151
+ trend_minimum_directional_consistency
152
+ ),
153
+ )
154
+ except ValueError as exc:
155
+ raise typer.BadParameter(str(exc)) from exc
156
+
157
+ try:
158
+ request = AnalysisRequest(
159
+ dag_id=dag_id,
160
+ policy=policy,
161
+ history_run_limit=history_run_limit,
162
+ dag_run_id=dag_run_id,
163
+ )
164
+ with AirflowClient(
165
+ history_run_limit=history_run_limit,
166
+ target_dag_run_id=request.dag_run_id,
167
+ ) as source:
168
+ analysis = analyze_dag(
169
+ dag_id=request.dag_id,
170
+ source=source,
171
+ policy=request.policy,
172
+ )
173
+ except AirflowApiError as exc:
174
+ console.print(f"[bold red]Airflow request failed:[/bold red] {exc}")
175
+ raise typer.Exit(code=1) from exc
176
+ except FlowSenseError as exc:
177
+ console.print(f"[bold red]Analysis failed:[/bold red] {exc}")
178
+ raise typer.Exit(code=1) from exc
179
+
180
+ if output is OutputFormat.JSON:
181
+ typer.echo(
182
+ json.dumps(
183
+ serialize_analysis(analysis),
184
+ indent=2,
185
+ ensure_ascii=False,
186
+ )
187
+ )
188
+ else:
189
+ render_analysis(console, analysis)
190
+
191
+ if fail_on is not None and severity_meets_threshold(
192
+ analysis.overall_severity,
193
+ Severity(fail_on.value.upper()),
194
+ ):
195
+ raise typer.Exit(code=ANALYSIS_THRESHOLD_EXIT_CODE)
@@ -0,0 +1,233 @@
1
+ from __future__ import annotations
2
+
3
+ from rich.console import Console
4
+ from rich.panel import Panel
5
+ from rich.table import Table
6
+ from rich.text import Text
7
+
8
+ from flowsense.domain import DAGAnalysis, Severity
9
+ from flowsense.domain.enums import SEVERITY_SCORE
10
+
11
+ _SEVERITY_STYLES = {
12
+ Severity.NORMAL: "green",
13
+ Severity.MEDIUM: "yellow",
14
+ Severity.HIGH: "bright_red",
15
+ Severity.CRITICAL: "bold red",
16
+ }
17
+
18
+
19
+ def _severity_text(severity: Severity) -> Text:
20
+ return Text(str(severity), style=_SEVERITY_STYLES[severity])
21
+
22
+
23
+ def _percent(value: float | None) -> str:
24
+ return f"{value:+.1f}%" if value is not None else "n/a"
25
+
26
+
27
+ def _render_summary(console: Console, analysis: DAGAnalysis) -> None:
28
+ dag_summary = analysis.summary
29
+ summary = Table.grid(padding=(0, 2))
30
+ summary.add_column(style="bold")
31
+ summary.add_column()
32
+ summary.add_row("DAG", analysis.dag_id)
33
+ if analysis.current_dag_run_id is not None:
34
+ summary.add_row("Current DAG run", analysis.current_dag_run_id)
35
+ summary.add_row("Runs analyzed", str(analysis.runs_analyzed))
36
+ summary.add_row("Overall severity", _severity_text(analysis.overall_severity))
37
+ summary.add_row(
38
+ "Task coverage",
39
+ f"{dag_summary.analyzed_tasks}/{dag_summary.total_tasks} "
40
+ f"({dag_summary.analysis_coverage_percent:.1f}%)",
41
+ )
42
+ summary.add_row("Anomalous tasks", str(dag_summary.anomalous_tasks))
43
+ summary.add_row("Anomalous handoffs", str(dag_summary.anomalous_handoffs))
44
+ summary.add_row("Affected tasks", str(dag_summary.affected_tasks))
45
+ summary.add_row(
46
+ "Structural signals",
47
+ f"{dag_summary.change_points} change points, {dag_summary.trends} trends",
48
+ )
49
+
50
+ if analysis.primary_origin is not None:
51
+ summary.add_row("Primary origin", analysis.primary_origin.task_id)
52
+ summary.add_row("Classification", str(analysis.primary_origin.classification))
53
+ summary.add_row(
54
+ "Propagation score",
55
+ f"{analysis.primary_origin.propagation_score:.2f}",
56
+ )
57
+
58
+ console.print(Panel(summary, title="FlowSense Analysis", expand=False))
59
+
60
+
61
+ def _render_task_drift(console: Console, analysis: DAGAnalysis) -> None:
62
+ table = Table(title="Task Drift")
63
+ table.add_column("Task")
64
+ table.add_column("Baseline", justify="right")
65
+ table.add_column("Current", justify="right")
66
+ table.add_column("Deviation", justify="right")
67
+ table.add_column("Z-Score", justify="right")
68
+ table.add_column("Severity")
69
+ table.add_column("Impact")
70
+
71
+ ordered_results = sorted(
72
+ analysis.drift_results.items(),
73
+ key=lambda item: (-SEVERITY_SCORE[item[1].severity], item[0]),
74
+ )
75
+
76
+ for task_id, result in ordered_results:
77
+ impact = analysis.task_impacts.get(task_id)
78
+ table.add_row(
79
+ task_id,
80
+ f"{result.baseline:.2f}s",
81
+ f"{result.current:.2f}s",
82
+ f"{result.deviation_percent:+.1f}%",
83
+ f"{result.robust_z_score:.2f}",
84
+ _severity_text(result.severity),
85
+ str(impact.classification) if impact else "-",
86
+ )
87
+
88
+ console.print(table)
89
+
90
+
91
+ def _render_handoff_drift(console: Console, analysis: DAGAnalysis) -> None:
92
+ if not analysis.handoff_drift_results:
93
+ return
94
+
95
+ table = Table(title="Handoff Drift")
96
+ table.add_column("Edge")
97
+ table.add_column("Baseline", justify="right")
98
+ table.add_column("Current", justify="right")
99
+ table.add_column("Deviation", justify="right")
100
+ table.add_column("Z-Score", justify="right")
101
+ table.add_column("Severity")
102
+
103
+ ordered_results = sorted(
104
+ analysis.handoff_drift_results.items(),
105
+ key=lambda item: (-SEVERITY_SCORE[item[1].severity], item[0]),
106
+ )
107
+
108
+ for (upstream, downstream), result in ordered_results:
109
+ table.add_row(
110
+ f"{upstream} -> {downstream}",
111
+ f"{result.baseline:.2f}s",
112
+ f"{result.current:.2f}s",
113
+ f"{result.deviation_percent:+.1f}%",
114
+ f"{result.robust_z_score:.2f}",
115
+ _severity_text(result.severity),
116
+ )
117
+
118
+ console.print(table)
119
+
120
+
121
+ def _render_change_points(console: Console, analysis: DAGAnalysis) -> None:
122
+ results = [
123
+ *analysis.change_point_results.values(),
124
+ *analysis.handoff_change_point_results.values(),
125
+ ]
126
+ if not results:
127
+ return
128
+
129
+ table = Table(title="Change Points")
130
+ table.add_column("Subject")
131
+ table.add_column("Direction")
132
+ table.add_column("Observation", justify="right")
133
+ table.add_column("Before", justify="right")
134
+ table.add_column("After", justify="right")
135
+ table.add_column("Change", justify="right")
136
+ table.add_column("Score", justify="right")
137
+
138
+ for result in sorted(results, key=lambda item: item.subject_id):
139
+ table.add_row(
140
+ result.subject_id,
141
+ str(result.direction),
142
+ str(result.change_index + 1),
143
+ f"{result.before_median:.2f}s",
144
+ f"{result.after_median:.2f}s",
145
+ _percent(result.change_percent),
146
+ f"{result.score:.2f}",
147
+ )
148
+
149
+ console.print(table)
150
+
151
+
152
+ def _render_trends(console: Console, analysis: DAGAnalysis) -> None:
153
+ results = [
154
+ *analysis.trend_results.values(),
155
+ *analysis.handoff_trend_results.values(),
156
+ ]
157
+ if not results:
158
+ return
159
+
160
+ table = Table(title="Trends")
161
+ table.add_column("Subject")
162
+ table.add_column("Direction")
163
+ table.add_column("Slope / run", justify="right")
164
+ table.add_column("Est. change", justify="right")
165
+ table.add_column("Change", justify="right")
166
+ table.add_column("Consistency", justify="right")
167
+ table.add_column("Score", justify="right")
168
+
169
+ for result in sorted(results, key=lambda item: item.subject_id):
170
+ table.add_row(
171
+ result.subject_id,
172
+ str(result.direction),
173
+ f"{result.slope_per_observation:+.2f}s",
174
+ f"{result.estimated_change:+.2f}s",
175
+ _percent(result.change_percent),
176
+ f"{result.directional_consistency:.0%}",
177
+ f"{result.score:.2f}",
178
+ )
179
+
180
+ console.print(table)
181
+
182
+
183
+ def _render_propagation(console: Console, analysis: DAGAnalysis) -> None:
184
+ if not analysis.propagation_results:
185
+ return
186
+
187
+ table = Table(title="Propagation")
188
+ table.add_column("Origin")
189
+ table.add_column("Path")
190
+ table.add_column("Affected", justify="right")
191
+ table.add_column("Score", justify="right")
192
+
193
+ for result in sorted(
194
+ analysis.propagation_results,
195
+ key=lambda item: (item.origin_task, item.path),
196
+ ):
197
+ table.add_row(
198
+ result.origin_task,
199
+ " -> ".join(result.path),
200
+ str(len(result.affected_tasks)),
201
+ f"{result.propagation_score:.2f}",
202
+ )
203
+
204
+ console.print(table)
205
+
206
+
207
+ def _render_diagnostics(console: Console, analysis: DAGAnalysis) -> None:
208
+ if not analysis.diagnostics:
209
+ return
210
+
211
+ table = Table(title="Diagnostics", title_style="bold yellow")
212
+ table.add_column("Code", style="yellow")
213
+ table.add_column("Subject")
214
+ table.add_column("Message")
215
+
216
+ for diagnostic in sorted(
217
+ analysis.diagnostics,
218
+ key=lambda item: (item.code, item.subject_id),
219
+ ):
220
+ table.add_row(diagnostic.code, diagnostic.subject_id, diagnostic.message)
221
+
222
+ console.print(table)
223
+
224
+
225
+ def render_analysis(console: Console, analysis: DAGAnalysis) -> None:
226
+ """Render a complete human-readable analysis report."""
227
+ _render_summary(console, analysis)
228
+ _render_task_drift(console, analysis)
229
+ _render_handoff_drift(console, analysis)
230
+ _render_change_points(console, analysis)
231
+ _render_trends(console, analysis)
232
+ _render_propagation(console, analysis)
233
+ _render_diagnostics(console, analysis)
File without changes
@@ -0,0 +1,5 @@
1
+ """Backward-compatible imports for the Airflow infrastructure adapter."""
2
+
3
+ from flowsense.infrastructure.airflow.client import PAGE_SIZE, AirflowClient
4
+
5
+ __all__ = ["PAGE_SIZE", "AirflowClient"]
flowsense/config.py ADDED
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+
6
+ from flowsense.domain import ConfigurationError
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class AirflowConfig:
11
+ base_url: str
12
+ username: str
13
+ password: str
14
+ api_version: str = "v2"
15
+ auth_mode: str = "token"
16
+ connect_timeout: float = 10.0
17
+ read_timeout: float = 10.0
18
+ max_retries: int = 2
19
+ retry_backoff: float = 0.5
20
+ history_run_limit: int = 100
21
+
22
+
23
+ def get_airflow_config() -> AirflowConfig:
24
+ base_url = os.getenv(
25
+ "AIRFLOW_BASE_URL",
26
+ "http://localhost:8080",
27
+ )
28
+
29
+ username = os.getenv("AIRFLOW_USERNAME")
30
+ password = os.getenv("AIRFLOW_PASSWORD")
31
+ api_version = os.getenv("AIRFLOW_API_VERSION", "v2")
32
+ auth_mode = os.getenv("AIRFLOW_AUTH_MODE", "token")
33
+ connect_timeout = _read_float("AIRFLOW_CONNECT_TIMEOUT", "10")
34
+ read_timeout = _read_float("AIRFLOW_READ_TIMEOUT", "10")
35
+ max_retries = _read_int("AIRFLOW_MAX_RETRIES", "2")
36
+ retry_backoff = _read_float("AIRFLOW_RETRY_BACKOFF", "0.5")
37
+ history_run_limit = _read_int("AIRFLOW_HISTORY_RUN_LIMIT", "100")
38
+
39
+ if not username:
40
+ raise ConfigurationError("AIRFLOW_USERNAME environment variable is required.")
41
+
42
+ if not password:
43
+ raise ConfigurationError("AIRFLOW_PASSWORD environment variable is required.")
44
+
45
+ return AirflowConfig(
46
+ base_url=base_url,
47
+ username=username,
48
+ password=password,
49
+ api_version=api_version,
50
+ auth_mode=auth_mode,
51
+ connect_timeout=connect_timeout,
52
+ read_timeout=read_timeout,
53
+ max_retries=max_retries,
54
+ retry_backoff=retry_backoff,
55
+ history_run_limit=history_run_limit,
56
+ )
57
+
58
+
59
+ def _read_float(name: str, default: str) -> float:
60
+ value = os.getenv(name, default)
61
+ try:
62
+ return float(value)
63
+ except ValueError as exc:
64
+ raise ConfigurationError(f"{name} must be a number.") from exc
65
+
66
+
67
+ def _read_int(name: str, default: str) -> int:
68
+ value = os.getenv(name, default)
69
+ try:
70
+ return int(value)
71
+ except ValueError as exc:
72
+ raise ConfigurationError(f"{name} must be an integer.") from exc