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,373 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from collections.abc import Callable
5
+ from datetime import UTC, datetime
6
+ from email.utils import parsedate_to_datetime
7
+ from typing import Any, Literal, Self
8
+
9
+ import httpx
10
+ from pydantic import ValidationError
11
+
12
+ from flowsense.config import get_airflow_config
13
+ from flowsense.domain import ConfigurationError, TaskRun
14
+ from flowsense.infrastructure.airflow.dto import (
15
+ AirflowDagRunDTO,
16
+ AirflowTaskDTO,
17
+ AirflowTaskInstanceDTO,
18
+ )
19
+ from flowsense.infrastructure.airflow.exceptions import (
20
+ AirflowApiError,
21
+ AirflowDagRunNotFoundError,
22
+ AirflowDataError,
23
+ )
24
+ from flowsense.infrastructure.airflow.mapper import (
25
+ map_dependencies,
26
+ map_task_instance,
27
+ )
28
+
29
+ PAGE_SIZE = 100
30
+ AirflowApiVersion = Literal["v1", "v2"]
31
+ AirflowAuthMode = Literal["basic", "token"]
32
+ RETRYABLE_STATUS_CODES = frozenset({429, 502, 503, 504})
33
+
34
+
35
+ class AirflowClient:
36
+ def __init__(
37
+ self,
38
+ base_url: str | None = None,
39
+ username: str | None = None,
40
+ password: str | None = None,
41
+ api_version: AirflowApiVersion | None = None,
42
+ auth_mode: AirflowAuthMode | None = None,
43
+ connect_timeout: float | None = None,
44
+ read_timeout: float | None = None,
45
+ max_retries: int | None = None,
46
+ retry_backoff: float | None = None,
47
+ history_run_limit: int | None = None,
48
+ target_dag_run_id: str | None = None,
49
+ http_client: httpx.Client | None = None,
50
+ sleep: Callable[[float], None] | None = None,
51
+ ):
52
+ config = get_airflow_config()
53
+
54
+ self.base_url = (base_url or config.base_url).rstrip("/")
55
+ self.username = username or config.username
56
+ self.password = password or config.password
57
+ self.api_version = api_version or config.api_version
58
+ self.auth_mode = auth_mode or config.auth_mode
59
+ self.connect_timeout = (
60
+ connect_timeout if connect_timeout is not None else config.connect_timeout
61
+ )
62
+ self.read_timeout = (
63
+ read_timeout if read_timeout is not None else config.read_timeout
64
+ )
65
+ self.max_retries = (
66
+ max_retries if max_retries is not None else config.max_retries
67
+ )
68
+ self.retry_backoff = (
69
+ retry_backoff if retry_backoff is not None else config.retry_backoff
70
+ )
71
+ self.history_run_limit = (
72
+ history_run_limit
73
+ if history_run_limit is not None
74
+ else config.history_run_limit
75
+ )
76
+ self.target_dag_run_id = target_dag_run_id
77
+
78
+ if self.api_version not in {"v1", "v2"}:
79
+ raise ConfigurationError("api_version must be 'v1' or 'v2'")
80
+
81
+ if self.auth_mode not in {"basic", "token"}:
82
+ raise ConfigurationError("auth_mode must be 'basic' or 'token'")
83
+ if self.connect_timeout <= 0:
84
+ raise ConfigurationError("connect_timeout must be positive")
85
+ if self.read_timeout <= 0:
86
+ raise ConfigurationError("read_timeout must be positive")
87
+ if self.max_retries < 0:
88
+ raise ConfigurationError("max_retries must be non-negative")
89
+ if self.retry_backoff < 0:
90
+ raise ConfigurationError("retry_backoff must be non-negative")
91
+ if self.history_run_limit < 2:
92
+ raise ConfigurationError("history_run_limit must be at least 2")
93
+ if self.target_dag_run_id is not None and not self.target_dag_run_id.strip():
94
+ raise ConfigurationError("target_dag_run_id must not be empty")
95
+
96
+ self._owns_http_client = http_client is None
97
+ self._http_client = http_client or httpx.Client(
98
+ timeout=httpx.Timeout(
99
+ connect=self.connect_timeout,
100
+ read=self.read_timeout,
101
+ write=self.read_timeout,
102
+ pool=self.connect_timeout,
103
+ )
104
+ )
105
+ self._sleep = sleep or time.sleep
106
+ self._token: str | None = None
107
+
108
+ def __enter__(self) -> Self:
109
+ return self
110
+
111
+ def __exit__(self, *args: object) -> None:
112
+ self.close()
113
+
114
+ def close(self) -> None:
115
+ if self._owns_http_client:
116
+ self._http_client.close()
117
+
118
+ def _request(
119
+ self,
120
+ method: str,
121
+ url: str,
122
+ **kwargs: object,
123
+ ) -> httpx.Response:
124
+ request_kwargs: dict[str, Any] = dict(kwargs)
125
+
126
+ for attempt in range(self.max_retries + 1):
127
+ try:
128
+ response = self._http_client.request(
129
+ method=method,
130
+ url=url,
131
+ **request_kwargs,
132
+ )
133
+ except httpx.RequestError as exc:
134
+ if attempt < self.max_retries:
135
+ self._sleep(self._backoff_delay(attempt))
136
+ continue
137
+
138
+ raise AirflowApiError(
139
+ method=method,
140
+ endpoint=exc.request.url.path,
141
+ ) from exc
142
+
143
+ if (
144
+ response.status_code in RETRYABLE_STATUS_CODES
145
+ and attempt < self.max_retries
146
+ ):
147
+ delay = self._retry_delay(response, attempt)
148
+ response.close()
149
+ self._sleep(delay)
150
+ continue
151
+
152
+ try:
153
+ response.raise_for_status()
154
+ except httpx.HTTPStatusError as exc:
155
+ raise AirflowApiError(
156
+ method=method,
157
+ endpoint=exc.request.url.path,
158
+ status_code=exc.response.status_code,
159
+ ) from exc
160
+
161
+ return response
162
+
163
+ raise RuntimeError("Airflow request retry loop exited unexpectedly")
164
+
165
+ def _backoff_delay(self, attempt: int) -> float:
166
+ return self.retry_backoff * (2**attempt)
167
+
168
+ def _retry_delay(self, response: httpx.Response, attempt: int) -> float:
169
+ retry_after = response.headers.get("Retry-After")
170
+ if retry_after is None:
171
+ return self._backoff_delay(attempt)
172
+
173
+ try:
174
+ return max(0.0, float(retry_after))
175
+ except ValueError:
176
+ try:
177
+ retry_at = parsedate_to_datetime(retry_after)
178
+ if retry_at.tzinfo is None:
179
+ retry_at = retry_at.replace(tzinfo=UTC)
180
+ return max(0.0, (retry_at - datetime.now(UTC)).total_seconds())
181
+ except (TypeError, ValueError, OverflowError):
182
+ return self._backoff_delay(attempt)
183
+
184
+ def _get_token(self) -> str:
185
+ if self._token:
186
+ return self._token
187
+
188
+ response = self._request(
189
+ method="POST",
190
+ url=f"{self.base_url}/auth/token",
191
+ json={
192
+ "username": self.username,
193
+ "password": self.password,
194
+ },
195
+ )
196
+
197
+ payload = self._response_json(response, "authentication response")
198
+ token = payload.get("access_token")
199
+ if not isinstance(token, str) or not token:
200
+ raise AirflowDataError("authentication response")
201
+ self._token = token
202
+ return token
203
+
204
+ @staticmethod
205
+ def _response_json(
206
+ response: httpx.Response,
207
+ resource: str,
208
+ ) -> dict[str, Any]:
209
+ try:
210
+ payload = response.json()
211
+ except ValueError as exc:
212
+ raise AirflowDataError(resource) from exc
213
+
214
+ if not isinstance(payload, dict):
215
+ raise AirflowDataError(resource)
216
+
217
+ return payload
218
+
219
+ def _headers(self) -> dict[str, str]:
220
+ headers = {"Accept": "application/json"}
221
+
222
+ if self.auth_mode == "token":
223
+ headers["Authorization"] = f"Bearer {self._get_token()}"
224
+
225
+ return headers
226
+
227
+ def _authentication(self) -> httpx.BasicAuth | None:
228
+ if self.auth_mode == "basic":
229
+ return httpx.BasicAuth(self.username, self.password)
230
+
231
+ return None
232
+
233
+ def _api_url(self, path: str) -> str:
234
+ return f"{self.base_url}/api/{self.api_version}{path}"
235
+
236
+ def _get_paginated(
237
+ self,
238
+ url: str,
239
+ collection_key: str,
240
+ ) -> dict:
241
+ items: list[dict] = []
242
+ offset = 0
243
+ last_page: dict = {}
244
+
245
+ while True:
246
+ request_kwargs: dict[str, object] = {
247
+ "headers": self._headers(),
248
+ "params": {"limit": PAGE_SIZE, "offset": offset},
249
+ }
250
+ authentication = self._authentication()
251
+ if authentication is not None:
252
+ request_kwargs["auth"] = authentication
253
+
254
+ response = self._request(
255
+ method="GET",
256
+ url=url,
257
+ **request_kwargs,
258
+ )
259
+
260
+ last_page = self._response_json(response, collection_key)
261
+ page_items = last_page.get(collection_key)
262
+ if not isinstance(page_items, list):
263
+ raise AirflowDataError(collection_key)
264
+ items.extend(page_items)
265
+ total_entries = last_page.get("total_entries")
266
+ if total_entries is not None and not isinstance(total_entries, int):
267
+ raise AirflowDataError(collection_key)
268
+
269
+ if not page_items:
270
+ break
271
+
272
+ if total_entries is not None and len(items) >= total_entries:
273
+ break
274
+
275
+ if total_entries is None and len(page_items) < PAGE_SIZE:
276
+ break
277
+
278
+ offset += len(page_items)
279
+
280
+ return {
281
+ **last_page,
282
+ collection_key: items,
283
+ "total_entries": last_page.get("total_entries", len(items)),
284
+ }
285
+
286
+ def get_dag_runs(self, dag_id: str) -> dict:
287
+ return self._get_paginated(
288
+ url=self._api_url(f"/dags/{dag_id}/dagRuns"),
289
+ collection_key="dag_runs",
290
+ )
291
+
292
+ def get_task_instances(
293
+ self,
294
+ dag_id: str,
295
+ dag_run_id: str,
296
+ ) -> dict:
297
+ return self._get_paginated(
298
+ url=self._api_url(f"/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances"),
299
+ collection_key="task_instances",
300
+ )
301
+
302
+ def collect_task_runs(self, dag_id: str) -> list[TaskRun]:
303
+ response = self.get_dag_runs(dag_id)
304
+ try:
305
+ dag_runs = [
306
+ AirflowDagRunDTO.model_validate(item) for item in response["dag_runs"]
307
+ ]
308
+ except ValidationError as exc:
309
+ raise AirflowDataError("DAG run") from exc
310
+
311
+ def run_timestamp(run: AirflowDagRunDTO) -> float:
312
+ timestamp = run.run_after or run.logical_date or run.queued_at
313
+ return timestamp.timestamp() if timestamp is not None else float("-inf")
314
+
315
+ successful_dag_runs = [run for run in dag_runs if run.state == "success"]
316
+ successful_dag_runs.sort(key=run_timestamp)
317
+ if self.target_dag_run_id is None:
318
+ selected_dag_runs = successful_dag_runs[-self.history_run_limit :]
319
+ else:
320
+ target_index = next(
321
+ (
322
+ index
323
+ for index, run in enumerate(successful_dag_runs)
324
+ if run.dag_run_id == self.target_dag_run_id
325
+ ),
326
+ None,
327
+ )
328
+ if target_index is None:
329
+ raise AirflowDagRunNotFoundError(dag_id, self.target_dag_run_id)
330
+
331
+ window_start = max(0, target_index - self.history_run_limit + 1)
332
+ selected_dag_runs = successful_dag_runs[window_start : target_index + 1]
333
+
334
+ task_runs: list[TaskRun] = []
335
+
336
+ for dag_run in selected_dag_runs:
337
+ response = self.get_task_instances(
338
+ dag_id=dag_id,
339
+ dag_run_id=dag_run.dag_run_id,
340
+ )
341
+ try:
342
+ task_instances = [
343
+ AirflowTaskInstanceDTO.model_validate(item)
344
+ for item in response["task_instances"]
345
+ ]
346
+ except ValidationError as exc:
347
+ raise AirflowDataError("task instance") from exc
348
+
349
+ task_runs.extend(
350
+ map_task_instance(
351
+ dag_id=dag_id,
352
+ dag_run_id=dag_run.dag_run_id,
353
+ task=task,
354
+ )
355
+ for task in task_instances
356
+ if task.state == "success"
357
+ )
358
+
359
+ return task_runs
360
+
361
+ def get_dag_tasks(self, dag_id: str) -> dict:
362
+ return self._get_paginated(
363
+ url=self._api_url(f"/dags/{dag_id}/tasks"),
364
+ collection_key="tasks",
365
+ )
366
+
367
+ def get_dag_dependencies(self, dag_id: str) -> dict[str, list[str]]:
368
+ response = self.get_dag_tasks(dag_id)
369
+ try:
370
+ tasks = [AirflowTaskDTO.model_validate(item) for item in response["tasks"]]
371
+ except ValidationError as exc:
372
+ raise AirflowDataError("DAG task") from exc
373
+ return map_dependencies(tasks)
@@ -0,0 +1,33 @@
1
+ from datetime import datetime
2
+
3
+ from pydantic import AliasChoices, BaseModel, ConfigDict, Field
4
+
5
+
6
+ class AirflowDTO(BaseModel):
7
+ model_config = ConfigDict(extra="ignore")
8
+
9
+
10
+ class AirflowDagRunDTO(AirflowDTO):
11
+ dag_run_id: str
12
+ state: str | None = None
13
+ logical_date: datetime | None = Field(
14
+ default=None,
15
+ validation_alias=AliasChoices("logical_date", "execution_date"),
16
+ )
17
+ run_after: datetime | None = None
18
+ queued_at: datetime | None = None
19
+
20
+
21
+ class AirflowTaskInstanceDTO(AirflowDTO):
22
+ task_id: str
23
+ state: str | None = None
24
+ start_date: datetime | None = None
25
+ end_date: datetime | None = None
26
+ duration: float | None = None
27
+ try_number: int = 0
28
+ map_index: int = -1
29
+
30
+
31
+ class AirflowTaskDTO(AirflowDTO):
32
+ task_id: str
33
+ downstream_task_ids: list[str] = Field(default_factory=list)
@@ -0,0 +1,31 @@
1
+ from flowsense.domain import FlowSenseError
2
+
3
+
4
+ class AirflowApiError(FlowSenseError):
5
+ def __init__(
6
+ self,
7
+ method: str,
8
+ endpoint: str,
9
+ status_code: int | None = None,
10
+ ) -> None:
11
+ self.method = method
12
+ self.endpoint = endpoint
13
+ self.status_code = status_code
14
+
15
+ status = f" with status {status_code}" if status_code is not None else ""
16
+ super().__init__(f"Airflow API {method} {endpoint} failed{status}.")
17
+
18
+
19
+ class AirflowDataError(FlowSenseError):
20
+ def __init__(self, resource: str) -> None:
21
+ self.resource = resource
22
+ super().__init__(f"Airflow returned invalid {resource} data.")
23
+
24
+
25
+ class AirflowDagRunNotFoundError(FlowSenseError):
26
+ def __init__(self, dag_id: str, dag_run_id: str) -> None:
27
+ self.dag_id = dag_id
28
+ self.dag_run_id = dag_run_id
29
+ super().__init__(
30
+ f"Successful Airflow DAG run {dag_run_id!r} was not found for {dag_id!r}."
31
+ )
@@ -0,0 +1,27 @@
1
+ from flowsense.domain import TaskRun
2
+ from flowsense.infrastructure.airflow.dto import (
3
+ AirflowTaskDTO,
4
+ AirflowTaskInstanceDTO,
5
+ )
6
+
7
+
8
+ def map_task_instance(
9
+ dag_id: str,
10
+ dag_run_id: str,
11
+ task: AirflowTaskInstanceDTO,
12
+ ) -> TaskRun:
13
+ return TaskRun(
14
+ dag_id=dag_id,
15
+ dag_run_id=dag_run_id,
16
+ task_id=task.task_id,
17
+ state=task.state,
18
+ start_date=task.start_date,
19
+ end_date=task.end_date,
20
+ duration=task.duration,
21
+ try_number=task.try_number,
22
+ map_index=task.map_index,
23
+ )
24
+
25
+
26
+ def map_dependencies(tasks: list[AirflowTaskDTO]) -> dict[str, list[str]]:
27
+ return {task.task_id: list(task.downstream_task_ids) for task in tasks}
File without changes
@@ -0,0 +1,75 @@
1
+ from __future__ import annotations
2
+
3
+ from mcp.server import MCPServer
4
+
5
+ from flowsense.application import AnalysisRequest, analyze_dag, serialize_analysis
6
+ from flowsense.domain import AnalysisPolicy, FlowSenseError, MappedTaskAggregation
7
+ from flowsense.infrastructure.airflow import AirflowClient
8
+
9
+ mcp = MCPServer("FlowSense Engine")
10
+
11
+
12
+ @mcp.tool()
13
+ def analyze_airflow_dag(
14
+ dag_id: str,
15
+ minimum_history: int = 5,
16
+ baseline_window: int | None = None,
17
+ medium_threshold: float = 2.0,
18
+ high_threshold: float = 3.5,
19
+ critical_threshold: float = 5.0,
20
+ change_point_detection_enabled: bool = True,
21
+ change_point_minimum_segment_size: int = 3,
22
+ change_point_score_threshold: float = 3.5,
23
+ trend_detection_enabled: bool = True,
24
+ trend_minimum_observations: int = 5,
25
+ trend_score_threshold: float = 3.5,
26
+ trend_minimum_directional_consistency: float = 0.6,
27
+ mapped_task_aggregation: MappedTaskAggregation = MappedTaskAggregation.MAX,
28
+ history_run_limit: int | None = None,
29
+ dag_run_id: str | None = None,
30
+ ) -> dict[str, object]:
31
+ """Analyze an Apache Airflow DAG for temporal drift and propagation."""
32
+ try:
33
+ request = AnalysisRequest(
34
+ dag_id=dag_id,
35
+ policy=AnalysisPolicy(
36
+ minimum_history=minimum_history,
37
+ baseline_window=baseline_window,
38
+ medium_threshold=medium_threshold,
39
+ high_threshold=high_threshold,
40
+ critical_threshold=critical_threshold,
41
+ mapped_task_aggregation=mapped_task_aggregation,
42
+ change_point_detection_enabled=change_point_detection_enabled,
43
+ change_point_minimum_segment_size=change_point_minimum_segment_size,
44
+ change_point_score_threshold=change_point_score_threshold,
45
+ trend_detection_enabled=trend_detection_enabled,
46
+ trend_minimum_observations=trend_minimum_observations,
47
+ trend_score_threshold=trend_score_threshold,
48
+ trend_minimum_directional_consistency=(
49
+ trend_minimum_directional_consistency
50
+ ),
51
+ ),
52
+ history_run_limit=history_run_limit,
53
+ dag_run_id=dag_run_id,
54
+ )
55
+ with AirflowClient(
56
+ history_run_limit=history_run_limit,
57
+ target_dag_run_id=request.dag_run_id,
58
+ ) as source:
59
+ analysis = analyze_dag(
60
+ dag_id=request.dag_id,
61
+ source=source,
62
+ policy=request.policy,
63
+ )
64
+ except FlowSenseError as exc:
65
+ raise RuntimeError(str(exc)) from exc
66
+
67
+ return serialize_analysis(analysis)
68
+
69
+
70
+ def main() -> None:
71
+ mcp.run()
72
+
73
+
74
+ if __name__ == "__main__":
75
+ main()
@@ -0,0 +1,7 @@
1
+ from flowsense.domain import AnalysisDiagnostic, DAGAnalysis, TaskRun
2
+
3
+ __all__ = [
4
+ "AnalysisDiagnostic",
5
+ "DAGAnalysis",
6
+ "TaskRun",
7
+ ]
@@ -0,0 +1,3 @@
1
+ from flowsense.domain import AnalysisDiagnostic, DAGAnalysis
2
+
3
+ __all__ = ["AnalysisDiagnostic", "DAGAnalysis"]
@@ -0,0 +1,3 @@
1
+ from flowsense.domain import TaskRun
2
+
3
+ __all__ = ["TaskRun"]
flowsense/py.typed ADDED
File without changes
flowsense/version.py ADDED
@@ -0,0 +1,6 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ __version__ = version("flowsense-engine")
5
+ except PackageNotFoundError:
6
+ __version__ = "0.0.0"