airflow-pytest-plugin 0.3.2__tar.gz → 0.4.0__tar.gz

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 (52) hide show
  1. {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/PKG-INFO +57 -5
  2. {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/README.md +51 -4
  3. {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/pyproject.toml +18 -1
  4. {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/src/airflow_pytest_plugin/config.py +58 -0
  5. {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/src/airflow_pytest_plugin/sources/filesystem.py +22 -6
  6. airflow_pytest_plugin-0.4.0/src/airflow_pytest_plugin/web/routes/failures.py +281 -0
  7. {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/src/airflow_pytest_plugin/web/routes/flaky.py +15 -2
  8. airflow_pytest_plugin-0.4.0/src/airflow_pytest_plugin/web/routes/monitoring.py +324 -0
  9. {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/src/airflow_pytest_plugin/web/routes/reports.py +205 -8
  10. {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/src/airflow_pytest_plugin/web/templates.py +635 -106
  11. {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/tests/conftest.py +7 -0
  12. {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/tests/test_config.py +27 -0
  13. airflow_pytest_plugin-0.4.0/tests/test_metrics.py +283 -0
  14. {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/tests/test_web_app.py +489 -9
  15. airflow_pytest_plugin-0.4.0/tests/ui/README.md +52 -0
  16. airflow_pytest_plugin-0.4.0/tests/ui/conftest.py +345 -0
  17. airflow_pytest_plugin-0.4.0/tests/ui/test_ui_airflow.py +63 -0
  18. airflow_pytest_plugin-0.4.0/tests/ui/test_ui_regression.py +162 -0
  19. airflow_pytest_plugin-0.4.0/tests/ui/test_ui_scale.py +118 -0
  20. airflow_pytest_plugin-0.3.2/src/airflow_pytest_plugin/web/routes/failures.py +0 -109
  21. airflow_pytest_plugin-0.3.2/src/airflow_pytest_plugin/web/routes/monitoring.py +0 -92
  22. {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/.gitignore +0 -0
  23. {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/LICENSE +0 -0
  24. {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/examples/example_dag.py +0 -0
  25. {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/src/airflow_pytest_plugin/__init__.py +0 -0
  26. {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/src/airflow_pytest_plugin/compat/__init__.py +0 -0
  27. {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/src/airflow_pytest_plugin/compat/airflow.py +0 -0
  28. {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/src/airflow_pytest_plugin/layout.py +0 -0
  29. {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/src/airflow_pytest_plugin/models.py +0 -0
  30. {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/src/airflow_pytest_plugin/plugin.py +0 -0
  31. {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/src/airflow_pytest_plugin/producer/__init__.py +0 -0
  32. {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/src/airflow_pytest_plugin/producer/archiving_parser.py +0 -0
  33. {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/src/airflow_pytest_plugin/retention.py +0 -0
  34. {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/src/airflow_pytest_plugin/sources/__init__.py +0 -0
  35. {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/src/airflow_pytest_plugin/sources/base.py +0 -0
  36. {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/src/airflow_pytest_plugin/version.py +0 -0
  37. {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/src/airflow_pytest_plugin/web/__init__.py +0 -0
  38. {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/src/airflow_pytest_plugin/web/__main__.py +0 -0
  39. {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/src/airflow_pytest_plugin/web/app.py +0 -0
  40. {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/src/airflow_pytest_plugin/web/routes/__init__.py +0 -0
  41. {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/src/airflow_pytest_plugin/web/routes/common.py +0 -0
  42. {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/src/airflow_pytest_plugin/web/routes/compare.py +0 -0
  43. {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/tests/test_archiving_parser.py +0 -0
  44. {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/tests/test_authz_integration.py +0 -0
  45. {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/tests/test_compat.py +0 -0
  46. {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/tests/test_filesystem_source.py +0 -0
  47. {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/tests/test_layout.py +0 -0
  48. {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/tests/test_models.py +0 -0
  49. {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/tests/test_package.py +0 -0
  50. {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/tests/test_plugin.py +0 -0
  51. {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/tests/test_retention.py +0 -0
  52. {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: airflow-pytest-plugin
3
- Version: 0.3.2
3
+ Version: 0.4.0
4
4
  Summary: View airflow-pytest-operator results in the Airflow 3 web UI.
5
5
  Project-URL: Homepage, https://github.com/IKrysanov/airflow-pytest-plugin
6
6
  Project-URL: Repository, https://github.com/IKrysanov/airflow-pytest-plugin
@@ -37,6 +37,11 @@ Requires-Dist: pytest>=7.0; extra == 'dev'
37
37
  Requires-Dist: ruff>=0.4; extra == 'dev'
38
38
  Provides-Extra: secure-xml
39
39
  Requires-Dist: defusedxml>=0.7; extra == 'secure-xml'
40
+ Provides-Extra: ui-test
41
+ Requires-Dist: fastapi>=0.110; extra == 'ui-test'
42
+ Requires-Dist: pytest-playwright>=0.5; extra == 'ui-test'
43
+ Requires-Dist: pytest>=7.0; extra == 'ui-test'
44
+ Requires-Dist: uvicorn>=0.23; extra == 'ui-test'
40
45
  Provides-Extra: web
41
46
  Requires-Dist: fastapi>=0.110; extra == 'web'
42
47
  Requires-Dist: uvicorn>=0.23; extra == 'web'
@@ -91,6 +96,7 @@ It has two halves that share one on-disk layout:
91
96
  - [HTTP API](#http-api)
92
97
  - [Access control (RBAC)](#access-control-rbac)
93
98
  - [Configuration](#configuration)
99
+ - [Prometheus metrics](#prometheus-metrics)
94
100
  - [Architecture (SOLID)](#architecture-solid)
95
101
  - [Development](#development)
96
102
  - [License](#license)
@@ -124,9 +130,21 @@ run; expanding a case offers its full **history**:
124
130
 
125
131
  ![Pytest Reports — flaky tests](https://raw.githubusercontent.com/IKrysanov/airflow-pytest-plugin/main/docs/screenshots/flaky.png)
126
132
 
133
+ **Slow tests & duration regressions** — the *Slowdowns* KPI opens a panel of tests
134
+ whose **execution time got slower** (recent-half average vs the older half, over a
135
+ configurable window) alongside the **slowest tests** by average duration. A test
136
+ that speeds back up drops off the list. Inside a run, the case table sorts by
137
+ execution time (slowest first) so the heaviest tests surface immediately.
138
+
139
+ ![Pytest Reports — slow tests & regressions](https://raw.githubusercontent.com/IKrysanov/airflow-pytest-plugin/main/docs/screenshots/slow.png)
140
+
127
141
  **Unique tests & failures** — the *Unique tests* KPI opens the searchable,
128
- paginated catalogue of every distinct test; the *Failures* KPI lists every
129
- failed/errored case across the visible runs a row jumps to that run:
142
+ paginated catalogue of every distinct test (each with its runs / pass-fail-error-skip
143
+ counts / average time); the *Failures* KPI shows what's broken **now** failures in each
144
+ dag·task's latest run, so the count shrinks as tests are fixed — grouped into **clusters
145
+ by normalized error** (biggest first) so common root causes surface instead of
146
+ per-failure spam. Expand a cluster to its tests, or open the same clusters scoped to one
147
+ run via the detail's *Error clusters* button:
130
148
 
131
149
  ![Pytest Reports — unique tests](https://raw.githubusercontent.com/IKrysanov/airflow-pytest-plugin/main/docs/screenshots/unique.png)
132
150
 
@@ -234,15 +252,18 @@ runtime. Endpoints (relative to the mount):
234
252
  | `GET /api/reports?dag_id=&run_id=` | summaries, newest first |
235
253
  | `GET /api/reports/{report_id}` | one report with per-case rows |
236
254
  | `GET /api/groups?dag_id=&task_id=` | runs aggregated by dag·task (count, pass-rate, avg duration, last status) |
237
- | `GET /api/failures?dag_id=&run_id=&task_id=` | failed/errored cases across the visible runs |
255
+ | `GET /api/failures?dag_id=&run_id=&task_id=&latest=` | failed/errored cases each dag·task's latest run by default (`latest=0` for full history) |
256
+ | `GET /api/failure-clusters?dag_id=&run_id=&task_id=&latest=` | failures grouped by normalized error signature (biggest first); latest-run-only by default |
238
257
  | `GET /api/compare?base=&head=` | per-test diff between two runs (newly failed / fixed / …) |
239
258
  | `GET /api/flaky?dag_id=&task_id=&window=` | flaky tests with score, trend, and a quarantine flag |
259
+ | `GET /api/slow?dag_id=&task_id=&window=` | duration regressions (tests whose execution time got slower) + the slowest tests by average |
240
260
  | `GET /api/test-history?dag_id=&task_id=&node_id=&limit=` | one test's outcome per run |
241
- | `GET /api/unique-tests?dag_id=&task_id=&run_id=&full=` | distinct test count (+ list when `full`) |
261
+ | `GET /api/unique-tests?dag_id=&task_id=&run_id=&full=` | distinct test count (+ when `full`, each test's runs / passed / failed / errors / skipped / avg duration) |
242
262
  | `DELETE /api/reports/{report_id}` | delete a report (RBAC-gated) |
243
263
  | `GET /api/reports/{report_id}/allure.zip` | raw Allure results as a zip (if any) |
244
264
  | `GET /api/health` | liveness + readiness: `status`, `ready`, `reports_root`(+`_exists`), `auth`, `secure_xml` |
245
265
  | `GET /api/version` | `{"name": ..., "version": ...}` from package metadata |
266
+ | `GET /api/metrics` | Prometheus exposition — opt-in, bearer-token (see [Prometheus metrics](#prometheus-metrics)) |
246
267
  | `GET /api/docs` | OpenAPI docs (Swagger UI) |
247
268
 
248
269
  The reads (`GET`) and the delete are gated by Airflow RBAC — see below.
@@ -308,7 +329,10 @@ Download them from a report's detail view, or `GET
308
329
  | `AIRFLOW_PYTEST_FLAKY_WINDOW` (env/cfg) | `30` | default recent runs the flaky detector scans |
309
330
  | `AIRFLOW_PYTEST_FLAKY_QUARANTINE_SCORE` (env/cfg) | `0.5` | flakiness score (0–1) that flags a test for quarantine |
310
331
  | `AIRFLOW_PYTEST_FLAKY_MIN_SCORE` (env/cfg) | `0.1` | flakiness score (0–1) below which a test is not counted as flaky |
332
+ | `AIRFLOW_PYTEST_SLOW_FACTOR` (env/cfg) | `1.3` | how much slower (recent-half avg ÷ older half, ≥1) a test must get to count as a duration regression |
333
+ | `AIRFLOW_PYTEST_SLOW_MIN_DELTA` (env/cfg) | `0.5` | minimum absolute slowdown in seconds for a regression to register (filters jittery fast tests) |
311
334
  | `AIRFLOW_PYTEST_SUCCESS_THRESHOLD` (env/cfg) | `0.85` | pass-rate (0–1) over executed tests at/above which a run counts as successful (*Passing runs*); `1.0` = strict, zero failures/errors |
335
+ | `AIRFLOW_PYTEST_METRICS_TOKEN` (env/cfg) | — | bearer token that **enables** the Prometheus `/api/metrics` endpoint; unset = disabled (see [below](#prometheus-metrics)) |
312
336
 
313
337
  **Enable / disable the reader.** Set `AIRFLOW_PYTEST_PLUGIN_ENABLE` to a falsey
314
338
  value (`0`, `false`, `no`, `off`) to stop the plugin registering its UI and API
@@ -328,6 +352,34 @@ single scan for `AIRFLOW_PYTEST_SCAN_CACHE_TTL` seconds (default `2.0`; deletes
328
352
  invalidate it immediately). New runs therefore appear within a couple of seconds (or
329
353
  on **Refresh**); set it to `0` for no caching, or higher on a very large tree.
330
354
 
355
+ ## Prometheus metrics
356
+
357
+ `GET /api/metrics` exposes per-dag·task gauges (from each dag·task's **latest** run)
358
+ in the Prometheus text format — `airflow_pytest_latest_{passed,failed,errors,skipped,
359
+ tests,pass_ratio,duration_seconds,success,run_timestamp_seconds}{dag_id,task_id}` and
360
+ `airflow_pytest_dagtask_runs{dag_id,task_id}`, plus globals
361
+ `airflow_pytest_{up,runs,dagtasks,latest_failures,series_truncated,build_info}` (all gauges).
362
+
363
+ It's **disabled by default** and turns on only when you set a scrape token; requests must
364
+ then present it as a bearer token (constant-time compared). The scrape is cheap and
365
+ bounded — one cached directory scan, summary-derived (no per-run reads), capped at 2000
366
+ series — so it's safe to poll frequently.
367
+
368
+ ```bash
369
+ export AIRFLOW_PYTEST_METRICS_TOKEN="$(openssl rand -hex 16)"
370
+ ```
371
+
372
+ ```yaml
373
+ # prometheus.yml
374
+ scrape_configs:
375
+ - job_name: airflow-pytest
376
+ metrics_path: /pytest-reports/api/metrics # the plugin's mount prefix
377
+ authorization:
378
+ credentials: "<AIRFLOW_PYTEST_METRICS_TOKEN>"
379
+ static_configs:
380
+ - targets: ["airflow-apiserver:8080"]
381
+ ```
382
+
331
383
  ## Retention (auto-cleanup)
332
384
 
333
385
  Reports accumulate forever unless you prune them. Set any of the
@@ -47,6 +47,7 @@ It has two halves that share one on-disk layout:
47
47
  - [HTTP API](#http-api)
48
48
  - [Access control (RBAC)](#access-control-rbac)
49
49
  - [Configuration](#configuration)
50
+ - [Prometheus metrics](#prometheus-metrics)
50
51
  - [Architecture (SOLID)](#architecture-solid)
51
52
  - [Development](#development)
52
53
  - [License](#license)
@@ -80,9 +81,21 @@ run; expanding a case offers its full **history**:
80
81
 
81
82
  ![Pytest Reports — flaky tests](https://raw.githubusercontent.com/IKrysanov/airflow-pytest-plugin/main/docs/screenshots/flaky.png)
82
83
 
84
+ **Slow tests & duration regressions** — the *Slowdowns* KPI opens a panel of tests
85
+ whose **execution time got slower** (recent-half average vs the older half, over a
86
+ configurable window) alongside the **slowest tests** by average duration. A test
87
+ that speeds back up drops off the list. Inside a run, the case table sorts by
88
+ execution time (slowest first) so the heaviest tests surface immediately.
89
+
90
+ ![Pytest Reports — slow tests & regressions](https://raw.githubusercontent.com/IKrysanov/airflow-pytest-plugin/main/docs/screenshots/slow.png)
91
+
83
92
  **Unique tests & failures** — the *Unique tests* KPI opens the searchable,
84
- paginated catalogue of every distinct test; the *Failures* KPI lists every
85
- failed/errored case across the visible runs a row jumps to that run:
93
+ paginated catalogue of every distinct test (each with its runs / pass-fail-error-skip
94
+ counts / average time); the *Failures* KPI shows what's broken **now** failures in each
95
+ dag·task's latest run, so the count shrinks as tests are fixed — grouped into **clusters
96
+ by normalized error** (biggest first) so common root causes surface instead of
97
+ per-failure spam. Expand a cluster to its tests, or open the same clusters scoped to one
98
+ run via the detail's *Error clusters* button:
86
99
 
87
100
  ![Pytest Reports — unique tests](https://raw.githubusercontent.com/IKrysanov/airflow-pytest-plugin/main/docs/screenshots/unique.png)
88
101
 
@@ -190,15 +203,18 @@ runtime. Endpoints (relative to the mount):
190
203
  | `GET /api/reports?dag_id=&run_id=` | summaries, newest first |
191
204
  | `GET /api/reports/{report_id}` | one report with per-case rows |
192
205
  | `GET /api/groups?dag_id=&task_id=` | runs aggregated by dag·task (count, pass-rate, avg duration, last status) |
193
- | `GET /api/failures?dag_id=&run_id=&task_id=` | failed/errored cases across the visible runs |
206
+ | `GET /api/failures?dag_id=&run_id=&task_id=&latest=` | failed/errored cases each dag·task's latest run by default (`latest=0` for full history) |
207
+ | `GET /api/failure-clusters?dag_id=&run_id=&task_id=&latest=` | failures grouped by normalized error signature (biggest first); latest-run-only by default |
194
208
  | `GET /api/compare?base=&head=` | per-test diff between two runs (newly failed / fixed / …) |
195
209
  | `GET /api/flaky?dag_id=&task_id=&window=` | flaky tests with score, trend, and a quarantine flag |
210
+ | `GET /api/slow?dag_id=&task_id=&window=` | duration regressions (tests whose execution time got slower) + the slowest tests by average |
196
211
  | `GET /api/test-history?dag_id=&task_id=&node_id=&limit=` | one test's outcome per run |
197
- | `GET /api/unique-tests?dag_id=&task_id=&run_id=&full=` | distinct test count (+ list when `full`) |
212
+ | `GET /api/unique-tests?dag_id=&task_id=&run_id=&full=` | distinct test count (+ when `full`, each test's runs / passed / failed / errors / skipped / avg duration) |
198
213
  | `DELETE /api/reports/{report_id}` | delete a report (RBAC-gated) |
199
214
  | `GET /api/reports/{report_id}/allure.zip` | raw Allure results as a zip (if any) |
200
215
  | `GET /api/health` | liveness + readiness: `status`, `ready`, `reports_root`(+`_exists`), `auth`, `secure_xml` |
201
216
  | `GET /api/version` | `{"name": ..., "version": ...}` from package metadata |
217
+ | `GET /api/metrics` | Prometheus exposition — opt-in, bearer-token (see [Prometheus metrics](#prometheus-metrics)) |
202
218
  | `GET /api/docs` | OpenAPI docs (Swagger UI) |
203
219
 
204
220
  The reads (`GET`) and the delete are gated by Airflow RBAC — see below.
@@ -264,7 +280,10 @@ Download them from a report's detail view, or `GET
264
280
  | `AIRFLOW_PYTEST_FLAKY_WINDOW` (env/cfg) | `30` | default recent runs the flaky detector scans |
265
281
  | `AIRFLOW_PYTEST_FLAKY_QUARANTINE_SCORE` (env/cfg) | `0.5` | flakiness score (0–1) that flags a test for quarantine |
266
282
  | `AIRFLOW_PYTEST_FLAKY_MIN_SCORE` (env/cfg) | `0.1` | flakiness score (0–1) below which a test is not counted as flaky |
283
+ | `AIRFLOW_PYTEST_SLOW_FACTOR` (env/cfg) | `1.3` | how much slower (recent-half avg ÷ older half, ≥1) a test must get to count as a duration regression |
284
+ | `AIRFLOW_PYTEST_SLOW_MIN_DELTA` (env/cfg) | `0.5` | minimum absolute slowdown in seconds for a regression to register (filters jittery fast tests) |
267
285
  | `AIRFLOW_PYTEST_SUCCESS_THRESHOLD` (env/cfg) | `0.85` | pass-rate (0–1) over executed tests at/above which a run counts as successful (*Passing runs*); `1.0` = strict, zero failures/errors |
286
+ | `AIRFLOW_PYTEST_METRICS_TOKEN` (env/cfg) | — | bearer token that **enables** the Prometheus `/api/metrics` endpoint; unset = disabled (see [below](#prometheus-metrics)) |
268
287
 
269
288
  **Enable / disable the reader.** Set `AIRFLOW_PYTEST_PLUGIN_ENABLE` to a falsey
270
289
  value (`0`, `false`, `no`, `off`) to stop the plugin registering its UI and API
@@ -284,6 +303,34 @@ single scan for `AIRFLOW_PYTEST_SCAN_CACHE_TTL` seconds (default `2.0`; deletes
284
303
  invalidate it immediately). New runs therefore appear within a couple of seconds (or
285
304
  on **Refresh**); set it to `0` for no caching, or higher on a very large tree.
286
305
 
306
+ ## Prometheus metrics
307
+
308
+ `GET /api/metrics` exposes per-dag·task gauges (from each dag·task's **latest** run)
309
+ in the Prometheus text format — `airflow_pytest_latest_{passed,failed,errors,skipped,
310
+ tests,pass_ratio,duration_seconds,success,run_timestamp_seconds}{dag_id,task_id}` and
311
+ `airflow_pytest_dagtask_runs{dag_id,task_id}`, plus globals
312
+ `airflow_pytest_{up,runs,dagtasks,latest_failures,series_truncated,build_info}` (all gauges).
313
+
314
+ It's **disabled by default** and turns on only when you set a scrape token; requests must
315
+ then present it as a bearer token (constant-time compared). The scrape is cheap and
316
+ bounded — one cached directory scan, summary-derived (no per-run reads), capped at 2000
317
+ series — so it's safe to poll frequently.
318
+
319
+ ```bash
320
+ export AIRFLOW_PYTEST_METRICS_TOKEN="$(openssl rand -hex 16)"
321
+ ```
322
+
323
+ ```yaml
324
+ # prometheus.yml
325
+ scrape_configs:
326
+ - job_name: airflow-pytest
327
+ metrics_path: /pytest-reports/api/metrics # the plugin's mount prefix
328
+ authorization:
329
+ credentials: "<AIRFLOW_PYTEST_METRICS_TOKEN>"
330
+ static_configs:
331
+ - targets: ["airflow-apiserver:8080"]
332
+ ```
333
+
287
334
  ## Retention (auto-cleanup)
288
335
 
289
336
  Reports accumulate forever unless you prune them. Set any of the
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "airflow-pytest-plugin"
7
- version = "0.3.2"
7
+ version = "0.4.0"
8
8
  description = "View airflow-pytest-operator results in the Airflow 3 web UI."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -60,6 +60,14 @@ dev = [
60
60
  "mypy>=1.8",
61
61
  "ruff>=0.4",
62
62
  ]
63
+ # Playwright UI regression suite (tests/ui, marker `ui`). Opt-in: heavy (real browser),
64
+ # run locally + in a dedicated CI job. After install: `playwright install chromium`.
65
+ ui-test = [
66
+ "pytest>=7.0",
67
+ "pytest-playwright>=0.5",
68
+ "fastapi>=0.110",
69
+ "uvicorn>=0.23", # the standalone dev server the UI tests drive
70
+ ]
63
71
 
64
72
  [project.urls]
65
73
  Homepage = "https://github.com/IKrysanov/airflow-pytest-plugin"
@@ -83,6 +91,15 @@ include = ["src", "tests", "examples", "README.md", "LICENSE"]
83
91
  [tool.pytest.ini_options]
84
92
  testpaths = ["tests"]
85
93
  pythonpath = ["src"]
94
+ # UI (Playwright) tests are opt-in and excluded by default so the unit suite stays fast
95
+ # and browser-free. Run them explicitly: `pytest -m ui` (standalone dev server) or
96
+ # `pytest -m ui_airflow` (embedded in a real Airflow api-server). Both need the
97
+ # `ui-test` extra + browsers; `ui_airflow` also needs Airflow installed.
98
+ addopts = ["-m", "not ui and not ui_airflow"]
99
+ markers = [
100
+ "ui: Playwright UI regression test (opt-in; needs the 'ui-test' extra + browsers)",
101
+ "ui_airflow: Playwright UI test against a real Airflow api-server with the plugin embedded",
102
+ ]
86
103
 
87
104
  [tool.coverage.run]
88
105
  branch = true
@@ -16,6 +16,7 @@
16
16
 
17
17
  from __future__ import annotations
18
18
 
19
+ import math
19
20
  import os
20
21
 
21
22
  from .compat import get_conf_value
@@ -149,6 +150,49 @@ def get_flaky_min_score() -> float:
149
150
  )
150
151
 
151
152
 
153
+ #: Duration-regression detector. A test is flagged "slower" when its recent-half
154
+ #: average duration is at least ``SLOW_FACTOR``× its older-half average AND the
155
+ #: absolute increase clears ``SLOW_MIN_DELTA`` seconds (the delta filters noise on
156
+ #: fast tests, where a tiny jitter can still beat the ratio). Shares the flaky window.
157
+ SLOW_FACTOR_ENV = "AIRFLOW_PYTEST_SLOW_FACTOR"
158
+ DEFAULT_SLOW_FACTOR = 1.3
159
+ SLOW_MIN_DELTA_ENV = "AIRFLOW_PYTEST_SLOW_MIN_DELTA"
160
+ DEFAULT_SLOW_MIN_DELTA = 0.5
161
+
162
+
163
+ def _positive_float_setting(
164
+ env_var: str, conf_key: str, default: float, *, minimum: float
165
+ ) -> float:
166
+ """A float ≥ ``minimum`` from env, then cfg; default on missing/invalid/below."""
167
+ raw = os.environ.get(env_var)
168
+ if raw is None or not raw.strip():
169
+ raw = get_conf_value(CONF_SECTION, conf_key)
170
+ if raw is None or not str(raw).strip():
171
+ return default
172
+ try:
173
+ value = float(str(raw).strip())
174
+ except ValueError:
175
+ return default
176
+ # Reject NaN/inf: they'd serialize to non-spec JSON ("NaN"/"Infinity") downstream.
177
+ if not math.isfinite(value):
178
+ return default
179
+ return value if value >= minimum else default
180
+
181
+
182
+ def get_slow_factor() -> float:
183
+ """Multiplier a test's recent-half avg duration must reach to count as a regression."""
184
+ return _positive_float_setting(
185
+ SLOW_FACTOR_ENV, "slow_factor", DEFAULT_SLOW_FACTOR, minimum=1.0
186
+ )
187
+
188
+
189
+ def get_slow_min_delta() -> float:
190
+ """Minimum absolute slowdown (seconds) for a duration regression to register."""
191
+ return _positive_float_setting(
192
+ SLOW_MIN_DELTA_ENV, "slow_min_delta", DEFAULT_SLOW_MIN_DELTA, minimum=0.0
193
+ )
194
+
195
+
152
196
  #: Pass-rate (0–1) at/above which a run counts as successful ("Passing runs"). The
153
197
  #: rate is over executed tests, so a run can carry a few failures and still pass.
154
198
  #: At 1.0 a run is successful only with zero failures/errors (the strict default of
@@ -164,6 +208,20 @@ def get_success_threshold() -> float:
164
208
  )
165
209
 
166
210
 
211
+ #: Opt-in bearer token for the Prometheus ``/api/metrics`` endpoint. Secure-by-default:
212
+ #: unset = the endpoint is DISABLED (404). When set, a scrape must send
213
+ #: ``Authorization: Bearer <token>``. Reads the env var, then the cfg key.
214
+ METRICS_TOKEN_ENV = "AIRFLOW_PYTEST_METRICS_TOKEN"
215
+
216
+
217
+ def get_metrics_token() -> str | None:
218
+ """The metrics scrape token, or ``None`` when the endpoint should stay disabled."""
219
+ raw = os.environ.get(METRICS_TOKEN_ENV)
220
+ if raw is None or not raw.strip():
221
+ raw = get_conf_value(CONF_SECTION, "metrics_token")
222
+ return raw.strip() if raw and raw.strip() else None
223
+
224
+
167
225
  def get_reports_root() -> str:
168
226
  """Resolve the report root directory (absolute path)."""
169
227
  env = os.environ.get(ENV_VAR)
@@ -19,6 +19,7 @@ from __future__ import annotations
19
19
  import io
20
20
  import json
21
21
  import logging
22
+ import math
22
23
  import os
23
24
  import shutil
24
25
  import threading
@@ -101,7 +102,17 @@ class FileSystemReportSource(ReportSource):
101
102
  if summary is not None:
102
103
  out.append(summary)
103
104
  # Newest first: ISO-8601 created_at sorts chronologically; missing sorts last.
104
- out.sort(key=lambda s: s.created_at or "", reverse=True)
105
+ # Tiebreak deterministically (try_number, run_id, map_index) so that on equal or
106
+ # missing created_at a stable "latest" run is chosen (e.g. a retry wins its try).
107
+ out.sort(
108
+ key=lambda s: (
109
+ s.created_at or "",
110
+ s.ref.try_number,
111
+ s.ref.run_id,
112
+ s.ref.map_index,
113
+ ),
114
+ reverse=True,
115
+ )
105
116
  return out
106
117
 
107
118
  def _all_summaries(self) -> list[ReportSummary]:
@@ -206,6 +217,10 @@ class FileSystemReportSource(ReportSource):
206
217
  if len(row) > 2 and isinstance(row[2], int | float)
207
218
  else 0.0
208
219
  )
220
+ # Guard NaN/Infinity from a corrupt meta so they can't reach a
221
+ # JSON response (json.dumps would emit non-spec NaN/Infinity).
222
+ if not math.isfinite(dur):
223
+ dur = 0.0
209
224
  out[str(row[0])] = {"outcome": str(row[1]), "duration": dur}
210
225
  return out
211
226
  # Older archive without the per-test map: parse junit.xml on demand.
@@ -217,12 +232,13 @@ class FileSystemReportSource(ReportSource):
217
232
  except Exception:
218
233
  _log.exception("Failed to parse JUnit report %s", report_path)
219
234
  return None
235
+
236
+ def _dur(c: Any) -> float:
237
+ d = float(getattr(c, "time", 0.0) or 0.0)
238
+ return d if math.isfinite(d) else 0.0
239
+
220
240
  return {
221
- c.node_id: {
222
- "outcome": c.outcome,
223
- "duration": float(getattr(c, "time", 0.0) or 0.0),
224
- }
225
- for c in result.cases
241
+ c.node_id: {"outcome": c.outcome, "duration": _dur(c)} for c in result.cases
226
242
  }
227
243
 
228
244
  def delete(self, ref: ReportRef) -> bool:
@@ -0,0 +1,281 @@
1
+ # Copyright 2026 the airflow-pytest-plugin contributors
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Failures route — failed/errored cases across the visible runs, flat or clustered."""
16
+
17
+ from __future__ import annotations
18
+
19
+ import re
20
+ from typing import Any
21
+
22
+ from fastapi import APIRouter, Depends
23
+ from fastapi.responses import JSONResponse
24
+
25
+ from .common import RouteDeps, ok
26
+
27
+ TAG = "failures"
28
+
29
+ #: Upper bound on failed cases returned, to keep the payload bounded.
30
+ _FAILURES_CAP = 5000
31
+ #: Cap on failing tests listed under a single cluster (the count is still exact).
32
+ _CLUSTER_TESTS_CAP = 200
33
+
34
+ # Volatile bits scrubbed from an error message so messages that differ only by run
35
+ # specifics collapse to one signature. Order matters: UUID before the generic digit
36
+ # rule (a UUID is mostly digits), hex addresses before digits too.
37
+ _RE_UUID = re.compile(r"[0-9a-fA-F]{8}-(?:[0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}")
38
+ _RE_HEX = re.compile(r"0x[0-9a-fA-F]+")
39
+ _RE_NUM = re.compile(r"\d+")
40
+ _RE_WS = re.compile(r"\s+")
41
+
42
+
43
+ def _error_line(message: str | None) -> str:
44
+ """First meaningful line of a case message (skips our ``--- section ---`` headers)."""
45
+ for line in (message or "").splitlines():
46
+ stripped = line.strip()
47
+ if stripped and not stripped.startswith("---"):
48
+ return stripped
49
+ return (message or "").strip()
50
+
51
+
52
+ def normalize_error(message: str | None) -> str:
53
+ """A stable signature for an error message: its first line with volatile bits masked.
54
+
55
+ Strips UUIDs, hex addresses and numbers (and collapses whitespace) so that
56
+ ``expected 5 got 7`` and ``expected 8 got 3`` share the signature
57
+ ``expected N got N``. Capped to keep signatures comparable and bounded.
58
+ """
59
+ line = _error_line(message)
60
+ line = _RE_UUID.sub("UUID", line)
61
+ line = _RE_HEX.sub(
62
+ "ADDR", line
63
+ ) # digit-free placeholder so the number rule won't touch it
64
+ line = _RE_NUM.sub("N", line)
65
+ return _RE_WS.sub(" ", line).strip()[:200]
66
+
67
+
68
+ def cluster_failures(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
69
+ """Group failing cases by normalized message (pure), most common cluster first.
70
+
71
+ Each cluster carries the ``signature``, an exact ``count``, the ``outcomes`` seen,
72
+ a representative raw ``sample`` line, and the (capped) list of failing ``tests``.
73
+ """
74
+ clusters: dict[str, dict[str, Any]] = {}
75
+ for it in items:
76
+ sig = normalize_error(it.get("message"))
77
+ c = clusters.get(sig)
78
+ if c is None:
79
+ c = clusters[sig] = {
80
+ "signature": sig,
81
+ "count": 0,
82
+ "sample": _error_line(it.get("message")),
83
+ "outcomes": set(),
84
+ "tests": [],
85
+ }
86
+ c["count"] += 1
87
+ c["outcomes"].add(it["outcome"])
88
+ if len(c["tests"]) < _CLUSTER_TESTS_CAP:
89
+ c["tests"].append(
90
+ {
91
+ k: it[k]
92
+ for k in (
93
+ "id",
94
+ "dag_id",
95
+ "task_id",
96
+ "run_id",
97
+ "created_at",
98
+ "node_id",
99
+ "outcome",
100
+ )
101
+ }
102
+ )
103
+ out = [{**c, "outcomes": sorted(c["outcomes"])} for c in clusters.values()]
104
+ out.sort(key=lambda c: (-c["count"], c["signature"]))
105
+ return out
106
+
107
+
108
+ def _collect_failures(
109
+ deps: RouteDeps,
110
+ user: Any,
111
+ *,
112
+ dag_id: str | None,
113
+ run_id: str | None,
114
+ task_q: str,
115
+ latest: bool,
116
+ with_message: bool,
117
+ ) -> tuple[list[dict[str, Any]], bool]:
118
+ """Collect failed/errored cases across readable runs (newest first).
119
+
120
+ When ``latest`` (the default for the views), only each **dag·task's** newest readable
121
+ run is considered — that's the pipeline's current state across all of its run_ids, so
122
+ a fixed test drops off once its next run is green and the list reflects *current*
123
+ breakage rather than every failure ever archived (older run_ids are history). Runs are
124
+ deduped per (dag_id, task_id); a retry wins its run via the scan's created_at/
125
+ try_number ordering. ``latest=False`` walks the full history. Returns ``(items, capped)``.
126
+ """
127
+ src, read_auth = deps.src, deps.read_auth
128
+ items: list[dict[str, Any]] = []
129
+ seen: set[tuple[str, str]] = set()
130
+ capped = False
131
+ for s in src.list_summaries(dag_id=dag_id, run_id=run_id): # newest first
132
+ if task_q and task_q not in s.ref.task_id.lower():
133
+ continue
134
+ if not read_auth(s.ref.dag_id, user):
135
+ continue
136
+ key = (s.ref.dag_id, s.ref.task_id)
137
+ if latest:
138
+ if key in seen: # an older run of a group whose newest we already passed
139
+ continue
140
+ seen.add(
141
+ key
142
+ ) # mark before the green check, so a green newest hides old fails
143
+ if (s.failed + s.errors) <= 0:
144
+ continue
145
+ detail = src.get_detail(s.ref)
146
+ if detail is None:
147
+ continue
148
+ for c in detail.cases:
149
+ if c.outcome not in ("failed", "error"):
150
+ continue
151
+ item = {
152
+ "id": s.ref.token,
153
+ "dag_id": s.ref.dag_id,
154
+ "task_id": s.ref.task_id,
155
+ "run_id": s.ref.run_id,
156
+ "created_at": s.created_at,
157
+ "node_id": c.node_id,
158
+ "outcome": c.outcome,
159
+ }
160
+ if with_message:
161
+ item["message"] = c.message
162
+ items.append(item)
163
+ if len(items) >= _FAILURES_CAP: # stop mid-run, don't over-read one big run
164
+ capped = True
165
+ break
166
+ if capped:
167
+ break
168
+ return items[:_FAILURES_CAP], capped
169
+
170
+
171
+ def build_router(deps: RouteDeps) -> APIRouter:
172
+ """Routes tagged ``failures``."""
173
+ router = APIRouter(tags=[TAG])
174
+ user_dep = deps.user_dep
175
+
176
+ @router.get(
177
+ "/api/failures",
178
+ summary="Failed cases across runs",
179
+ responses=ok(
180
+ {
181
+ "failures": [
182
+ {
183
+ "id": "YXBpX2dhdGV3YXl8...",
184
+ "dag_id": "api_gateway",
185
+ "task_id": "integration_tests",
186
+ "run_id": "scheduled__2026-06-20",
187
+ "created_at": "2026-06-20T07:00:00+00:00",
188
+ "node_id": "tests/api.py::test_auth",
189
+ "outcome": "failed",
190
+ }
191
+ ],
192
+ "total": 1,
193
+ "capped": False,
194
+ }
195
+ ),
196
+ )
197
+ def failures(
198
+ dag_id: str | None = None,
199
+ run_id: str | None = None,
200
+ task_id: str | None = None,
201
+ latest: bool = True,
202
+ user: Any = Depends(user_dep), # noqa: B008 - FastAPI dependency idiom
203
+ ) -> JSONResponse:
204
+ """Currently-failing cases (one flat list), newest run first.
205
+
206
+ By default only each dag·task's **latest** readable run is considered, so a
207
+ fixed test drops off once its next run is green — the list reflects what's broken
208
+ *now* and shrinks as code improves. Pass ``latest=0`` for the full history.
209
+ Filters mirror the run list (``dag_id`` / ``run_id`` / ``task_id``); RBAC; capped
210
+ at ``5000`` (``capped`` flags truncation).
211
+ """
212
+ items, capped = _collect_failures(
213
+ deps,
214
+ user,
215
+ dag_id=dag_id,
216
+ run_id=run_id,
217
+ task_q=(task_id or "").lower(),
218
+ latest=latest,
219
+ with_message=False,
220
+ )
221
+ return JSONResponse({"failures": items, "total": len(items), "capped": capped})
222
+
223
+ @router.get(
224
+ "/api/failure-clusters",
225
+ summary="Failures grouped by error",
226
+ responses=ok(
227
+ {
228
+ "clusters": [
229
+ {
230
+ "signature": "AssertionError: expected N got N",
231
+ "count": 12,
232
+ "sample": "AssertionError: expected 5 got 7",
233
+ "outcomes": ["failed"],
234
+ "tests": [
235
+ {
236
+ "id": "YXBpX2dhdGV3YXl8...",
237
+ "dag_id": "api_gateway",
238
+ "task_id": "integration_tests",
239
+ "run_id": "scheduled__2026-06-20",
240
+ "created_at": "2026-06-20T07:00:00+00:00",
241
+ "node_id": "tests/api.py::test_auth",
242
+ "outcome": "failed",
243
+ }
244
+ ],
245
+ }
246
+ ],
247
+ "total": 12,
248
+ "capped": False,
249
+ }
250
+ ),
251
+ )
252
+ def failure_clusters(
253
+ dag_id: str | None = None,
254
+ run_id: str | None = None,
255
+ task_id: str | None = None,
256
+ latest: bool = True,
257
+ user: Any = Depends(user_dep), # noqa: B008 - FastAPI dependency idiom
258
+ ) -> JSONResponse:
259
+ """Currently-failing cases grouped by a normalized error signature, biggest first.
260
+
261
+ Same scan/filters/RBAC/cap as ``/api/failures``, and likewise defaults to each
262
+ dag·task's **latest** run (``latest=0`` for full history) — so clusters reflect
263
+ what's broken now and shrink as tests are fixed. Instead of a flat list it
264
+ returns clusters, so common root causes surface instead of per-failure spam; each
265
+ has an exact ``count``, a representative ``sample`` message, and the (capped)
266
+ failing ``tests``. Scope to one ``run_id`` for the in-run view.
267
+ """
268
+ items, capped = _collect_failures(
269
+ deps,
270
+ user,
271
+ dag_id=dag_id,
272
+ run_id=run_id,
273
+ task_q=(task_id or "").lower(),
274
+ latest=latest,
275
+ with_message=True,
276
+ )
277
+ return JSONResponse(
278
+ {"clusters": cluster_failures(items), "total": len(items), "capped": capped}
279
+ )
280
+
281
+ return router