airflow-pytest-plugin 0.2.1__tar.gz → 0.3.1__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 (48) hide show
  1. {airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/PKG-INFO +89 -11
  2. {airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/README.md +88 -10
  3. {airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/pyproject.toml +1 -1
  4. {airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/__init__.py +4 -0
  5. airflow_pytest_plugin-0.3.1/src/airflow_pytest_plugin/config.py +177 -0
  6. {airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/models.py +13 -0
  7. {airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/plugin.py +11 -3
  8. {airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/producer/archiving_parser.py +15 -0
  9. airflow_pytest_plugin-0.3.1/src/airflow_pytest_plugin/retention.py +253 -0
  10. {airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/sources/base.py +17 -0
  11. {airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/sources/filesystem.py +139 -29
  12. airflow_pytest_plugin-0.3.1/src/airflow_pytest_plugin/web/app.py +155 -0
  13. airflow_pytest_plugin-0.3.1/src/airflow_pytest_plugin/web/routes/__init__.py +20 -0
  14. airflow_pytest_plugin-0.3.1/src/airflow_pytest_plugin/web/routes/common.py +53 -0
  15. airflow_pytest_plugin-0.3.1/src/airflow_pytest_plugin/web/routes/compare.py +94 -0
  16. airflow_pytest_plugin-0.3.1/src/airflow_pytest_plugin/web/routes/failures.py +89 -0
  17. airflow_pytest_plugin-0.3.1/src/airflow_pytest_plugin/web/routes/flaky.py +162 -0
  18. airflow_pytest_plugin-0.3.1/src/airflow_pytest_plugin/web/routes/monitoring.py +75 -0
  19. airflow_pytest_plugin-0.3.1/src/airflow_pytest_plugin/web/routes/reports.py +219 -0
  20. {airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/web/templates.py +1003 -91
  21. {airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/tests/conftest.py +29 -0
  22. {airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/tests/test_archiving_parser.py +14 -0
  23. airflow_pytest_plugin-0.3.1/tests/test_config.py +145 -0
  24. {airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/tests/test_filesystem_source.py +138 -1
  25. {airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/tests/test_models.py +23 -0
  26. {airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/tests/test_plugin.py +14 -0
  27. airflow_pytest_plugin-0.3.1/tests/test_retention.py +287 -0
  28. airflow_pytest_plugin-0.3.1/tests/test_web_app.py +759 -0
  29. airflow_pytest_plugin-0.2.1/src/airflow_pytest_plugin/config.py +0 -39
  30. airflow_pytest_plugin-0.2.1/src/airflow_pytest_plugin/web/app.py +0 -235
  31. airflow_pytest_plugin-0.2.1/tests/test_config.py +0 -42
  32. airflow_pytest_plugin-0.2.1/tests/test_web_app.py +0 -350
  33. {airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/.gitignore +0 -0
  34. {airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/LICENSE +0 -0
  35. {airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/examples/example_dag.py +0 -0
  36. {airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/compat/__init__.py +0 -0
  37. {airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/compat/airflow.py +0 -0
  38. {airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/layout.py +0 -0
  39. {airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/producer/__init__.py +0 -0
  40. {airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/sources/__init__.py +0 -0
  41. {airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/version.py +0 -0
  42. {airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/web/__init__.py +0 -0
  43. {airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/web/__main__.py +0 -0
  44. {airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/tests/test_authz_integration.py +0 -0
  45. {airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/tests/test_compat.py +0 -0
  46. {airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/tests/test_layout.py +0 -0
  47. {airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/tests/test_package.py +0 -0
  48. {airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/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.2.1
3
+ Version: 0.3.1
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
@@ -70,7 +70,9 @@ The operator runs a `pytest` suite as an Airflow task and parses the JUnit
70
70
  report into a structured result. This plugin archives each of those reports —
71
71
  keyed by `dag_id / run_id / task_id / try` — and serves a small web UI to browse
72
72
  them: pass/fail counts per run, durations, and the per-test breakdown (with
73
- failure messages) for any run.
73
+ failure messages) for any run. On top of that it adds **cross-run analytics** —
74
+ flaky-test detection, per-test history, run-to-run comparison, a duration
75
+ histogram, and a searchable catalogue of unique tests.
74
76
 
75
77
  It has two halves that share one on-disk layout:
76
78
 
@@ -96,19 +98,34 @@ It has two halves that share one on-disk layout:
96
98
  ## Screenshots
97
99
 
98
100
  **Overview** — the run list with the historical chart (per-status legend
99
- toggles, run numbers, and a carousel beyond 30 runs), KPI cards, and Airflow-
100
- matched colours and font:
101
+ toggles, run numbers, a carousel beyond 30 runs, an optional **pass-rate trend
102
+ line** with a success-threshold gridline, and **tick runs in the list to filter
103
+ the chart to just their trend**) beside a **flaky-tests panel** (with its own
104
+ search and a quarantined-only toggle), KPI cards (including a clickable **unique
105
+ tests** count), and Airflow-matched colours and font:
101
106
 
102
107
  ![Pytest Reports — overview](https://raw.githubusercontent.com/IKrysanov/airflow-pytest-plugin/main/docs/screenshots/overview.png)
103
108
 
104
- **A single run** — a clickable success donut (pass-rate in the centre; click a
105
- slice to filter by status), the counts, and every test's captured output on
106
- expand:
109
+ **A single run** — a clickable success donut (pass-rate over the test count;
110
+ click a slice to filter by status), a **test-duration histogram** (10-second
111
+ buckets, scrollable), case search / group-by-module, and every test's captured
112
+ output on expand:
107
113
 
108
114
  ![Pytest Reports — a single run](https://raw.githubusercontent.com/IKrysanov/airflow-pytest-plugin/main/docs/screenshots/detail.png)
109
115
 
110
- **Failed tests** — clicking the *Failures* KPI lists every failed/errored case
111
- across the visible runs (paginated); a row jumps to that run:
116
+ **Flaky tests & comparison** — from a run, *Flaky tests* lists the tests that
117
+ both pass and fail across recent runs, each with a recent-outcome strip, a
118
+ **flakiness score**, a **trend** arrow, and a **quarantine** badge, over a
119
+ configurable analysis window; *Compare to previous* diffs it against the prior
120
+ run; expanding a case offers its full **history**:
121
+
122
+ ![Pytest Reports — flaky tests](https://raw.githubusercontent.com/IKrysanov/airflow-pytest-plugin/main/docs/screenshots/flaky.png)
123
+
124
+ **Unique tests & failures** — the *Unique tests* KPI opens the searchable,
125
+ paginated catalogue of every distinct test; the *Failures* KPI lists every
126
+ failed/errored case across the visible runs — a row jumps to that run:
127
+
128
+ ![Pytest Reports — unique tests](https://raw.githubusercontent.com/IKrysanov/airflow-pytest-plugin/main/docs/screenshots/unique.png)
112
129
 
113
130
  ![Pytest Reports — failed tests](https://raw.githubusercontent.com/IKrysanov/airflow-pytest-plugin/main/docs/screenshots/failures.png)
114
131
 
@@ -214,10 +231,15 @@ runtime. Endpoints (relative to the mount):
214
231
  | `GET /api/reports?dag_id=&run_id=` | summaries, newest first |
215
232
  | `GET /api/reports/{report_id}` | one report with per-case rows |
216
233
  | `GET /api/failures?dag_id=&run_id=&task_id=` | failed/errored cases across the visible runs |
234
+ | `GET /api/compare?base=&head=` | per-test diff between two runs (newly failed / fixed / …) |
235
+ | `GET /api/flaky?dag_id=&task_id=&window=` | flaky tests with score, trend, and a quarantine flag |
236
+ | `GET /api/test-history?dag_id=&task_id=&node_id=&limit=` | one test's outcome per run |
237
+ | `GET /api/unique-tests?dag_id=&task_id=&run_id=&full=` | distinct test count (+ list when `full`) |
217
238
  | `DELETE /api/reports/{report_id}` | delete a report (RBAC-gated) |
218
239
  | `GET /api/reports/{report_id}/allure.zip` | raw Allure results as a zip (if any) |
219
- | `GET /api/health` | `{"status": "ok"}` |
220
- | `GET /api/docs` | OpenAPI docs |
240
+ | `GET /api/health` | liveness + readiness: `status`, `ready`, `reports_root`(+`_exists`), `auth`, `secure_xml` |
241
+ | `GET /api/version` | `{"name": ..., "version": ...}` from package metadata |
242
+ | `GET /api/docs` | OpenAPI docs (Swagger UI) |
221
243
 
222
244
  The reads (`GET`) and the delete are gated by Airflow RBAC — see below.
223
245
 
@@ -274,6 +296,61 @@ Download them from a report's detail view, or `GET
274
296
  | `AIRFLOW_PYTEST_REPORTS_ROOT` (env) | — | report root (highest precedence) |
275
297
  | `[pytest_reports] reports_root` (cfg) | — | report root |
276
298
  | built-in default | `/opt/airflow/pytest-reports` | fallback |
299
+ | `AIRFLOW_PYTEST_PLUGIN_ENABLE` (env) | `True` | reader on/off — see below |
300
+ | `AIRFLOW_PYTEST_SCAN_CACHE_TTL` (env) | `2.0` | seconds a directory scan is reused (`0` disables) |
301
+ | `AIRFLOW_PYTEST_RETENTION_MAX_AGE_DAYS` (env/cfg) | — | delete runs older than N days |
302
+ | `AIRFLOW_PYTEST_RETENTION_MAX_RUNS` (env/cfg) | — | keep at most N newest runs per dag·task |
303
+ | `AIRFLOW_PYTEST_RETENTION_MAX_TOTAL_MB` (env/cfg) | — | total report-tree budget in MB |
304
+ | `AIRFLOW_PYTEST_FLAKY_WINDOW` (env/cfg) | `30` | default recent runs the flaky detector scans |
305
+ | `AIRFLOW_PYTEST_FLAKY_QUARANTINE_SCORE` (env/cfg) | `0.5` | flakiness score (0–1) that flags a test for quarantine |
306
+ | `AIRFLOW_PYTEST_FLAKY_MIN_SCORE` (env/cfg) | `0.1` | flakiness score (0–1) below which a test is not counted as flaky |
307
+ | `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 |
308
+
309
+ **Enable / disable the reader.** Set `AIRFLOW_PYTEST_PLUGIN_ENABLE` to a falsey
310
+ value (`0`, `false`, `no`, `off`) to stop the plugin registering its UI and API
311
+ with Airflow; any other value, or leaving it unset, keeps it on (the default).
312
+ This is a kill switch for the reader only — the producer-side
313
+ `ArchivingResultParser` still archives reports regardless. It is read once at
314
+ plugin discovery, so toggling it takes effect on the next API-server restart.
315
+
316
+ ```bash
317
+ export AIRFLOW_PYTEST_PLUGIN_ENABLE=false # hide the Pytest Reports UI + API
318
+ ```
319
+
320
+ **Scan cache.** Loading the home page hits several summary-driven endpoints (the run
321
+ list, the flaky panel, the unique-tests count), and the filter box queries as you
322
+ type. To avoid walking the report tree once per call, the filesystem source reuses a
323
+ single scan for `AIRFLOW_PYTEST_SCAN_CACHE_TTL` seconds (default `2.0`; deletes
324
+ invalidate it immediately). New runs therefore appear within a couple of seconds (or
325
+ on **Refresh**); set it to `0` for no caching, or higher on a very large tree.
326
+
327
+ ## Retention (auto-cleanup)
328
+
329
+ Reports accumulate forever unless you prune them. Set any of the
330
+ `AIRFLOW_PYTEST_RETENTION_*` knobs above (all opt-in; unset = keep everything) and
331
+ schedule `prune_reports` from a maintenance DAG:
332
+
333
+ ```python
334
+ from airflow import DAG
335
+ from airflow.providers.standard.operators.python import PythonOperator
336
+ from airflow_pytest_plugin import prune_reports
337
+
338
+ with DAG("pytest_reports_retention", schedule="@daily", catchup=False, ...):
339
+ PythonOperator(task_id="prune", python_callable=prune_reports)
340
+ ```
341
+
342
+ Each knob is a dimension and they combine as a union — a run is deleted if **any**
343
+ applies:
344
+
345
+ - **age** — older than `…_MAX_AGE_DAYS`;
346
+ - **count** — beyond the newest `…_MAX_RUNS` of its dag·task;
347
+ - **size** — oldest-first until the tree fits `…_MAX_TOTAL_MB`.
348
+
349
+ The **newest run of each dag·task is always kept**, so a task's latest result never
350
+ disappears. `prune_reports(dry_run=True)` reports what *would* go without deleting
351
+ (its `RetentionResult` carries `deleted`, `freed_bytes`, `scanned`). Cleanup is
352
+ scheduler-driven — the plugin never deletes on its own. For a custom policy, build a
353
+ `RetentionPolicy(...)` and pass it (`prune_reports(policy)`).
277
354
 
278
355
  ## Architecture (SOLID)
279
356
 
@@ -285,6 +362,7 @@ Mirrors the operator's layering — each piece has one reason to change:
285
362
  | `producer.ArchivingResultParser` | write JUnit XML + `meta.json` (extends the operator's parser) |
286
363
  | `sources.ReportSource` / `FileSystemReportSource` | read/index reports behind an interface (Dependency Inversion) |
287
364
  | `web.create_app` | map HTTP onto a `ReportSource` — knows nothing about the filesystem |
365
+ | `retention` | pure `select_expired` decision + a `prune` orchestrator over any `ReportSource` |
288
366
  | `plugin.PytestReportsPlugin` | register the app with Airflow |
289
367
  | `compat` | the only module that imports Airflow; version differences resolved once |
290
368
  | `models` | JSON-serializable view types; the web layer never sees operator types |
@@ -26,7 +26,9 @@ The operator runs a `pytest` suite as an Airflow task and parses the JUnit
26
26
  report into a structured result. This plugin archives each of those reports —
27
27
  keyed by `dag_id / run_id / task_id / try` — and serves a small web UI to browse
28
28
  them: pass/fail counts per run, durations, and the per-test breakdown (with
29
- failure messages) for any run.
29
+ failure messages) for any run. On top of that it adds **cross-run analytics** —
30
+ flaky-test detection, per-test history, run-to-run comparison, a duration
31
+ histogram, and a searchable catalogue of unique tests.
30
32
 
31
33
  It has two halves that share one on-disk layout:
32
34
 
@@ -52,19 +54,34 @@ It has two halves that share one on-disk layout:
52
54
  ## Screenshots
53
55
 
54
56
  **Overview** — the run list with the historical chart (per-status legend
55
- toggles, run numbers, and a carousel beyond 30 runs), KPI cards, and Airflow-
56
- matched colours and font:
57
+ toggles, run numbers, a carousel beyond 30 runs, an optional **pass-rate trend
58
+ line** with a success-threshold gridline, and **tick runs in the list to filter
59
+ the chart to just their trend**) beside a **flaky-tests panel** (with its own
60
+ search and a quarantined-only toggle), KPI cards (including a clickable **unique
61
+ tests** count), and Airflow-matched colours and font:
57
62
 
58
63
  ![Pytest Reports — overview](https://raw.githubusercontent.com/IKrysanov/airflow-pytest-plugin/main/docs/screenshots/overview.png)
59
64
 
60
- **A single run** — a clickable success donut (pass-rate in the centre; click a
61
- slice to filter by status), the counts, and every test's captured output on
62
- expand:
65
+ **A single run** — a clickable success donut (pass-rate over the test count;
66
+ click a slice to filter by status), a **test-duration histogram** (10-second
67
+ buckets, scrollable), case search / group-by-module, and every test's captured
68
+ output on expand:
63
69
 
64
70
  ![Pytest Reports — a single run](https://raw.githubusercontent.com/IKrysanov/airflow-pytest-plugin/main/docs/screenshots/detail.png)
65
71
 
66
- **Failed tests** — clicking the *Failures* KPI lists every failed/errored case
67
- across the visible runs (paginated); a row jumps to that run:
72
+ **Flaky tests & comparison** — from a run, *Flaky tests* lists the tests that
73
+ both pass and fail across recent runs, each with a recent-outcome strip, a
74
+ **flakiness score**, a **trend** arrow, and a **quarantine** badge, over a
75
+ configurable analysis window; *Compare to previous* diffs it against the prior
76
+ run; expanding a case offers its full **history**:
77
+
78
+ ![Pytest Reports — flaky tests](https://raw.githubusercontent.com/IKrysanov/airflow-pytest-plugin/main/docs/screenshots/flaky.png)
79
+
80
+ **Unique tests & failures** — the *Unique tests* KPI opens the searchable,
81
+ paginated catalogue of every distinct test; the *Failures* KPI lists every
82
+ failed/errored case across the visible runs — a row jumps to that run:
83
+
84
+ ![Pytest Reports — unique tests](https://raw.githubusercontent.com/IKrysanov/airflow-pytest-plugin/main/docs/screenshots/unique.png)
68
85
 
69
86
  ![Pytest Reports — failed tests](https://raw.githubusercontent.com/IKrysanov/airflow-pytest-plugin/main/docs/screenshots/failures.png)
70
87
 
@@ -170,10 +187,15 @@ runtime. Endpoints (relative to the mount):
170
187
  | `GET /api/reports?dag_id=&run_id=` | summaries, newest first |
171
188
  | `GET /api/reports/{report_id}` | one report with per-case rows |
172
189
  | `GET /api/failures?dag_id=&run_id=&task_id=` | failed/errored cases across the visible runs |
190
+ | `GET /api/compare?base=&head=` | per-test diff between two runs (newly failed / fixed / …) |
191
+ | `GET /api/flaky?dag_id=&task_id=&window=` | flaky tests with score, trend, and a quarantine flag |
192
+ | `GET /api/test-history?dag_id=&task_id=&node_id=&limit=` | one test's outcome per run |
193
+ | `GET /api/unique-tests?dag_id=&task_id=&run_id=&full=` | distinct test count (+ list when `full`) |
173
194
  | `DELETE /api/reports/{report_id}` | delete a report (RBAC-gated) |
174
195
  | `GET /api/reports/{report_id}/allure.zip` | raw Allure results as a zip (if any) |
175
- | `GET /api/health` | `{"status": "ok"}` |
176
- | `GET /api/docs` | OpenAPI docs |
196
+ | `GET /api/health` | liveness + readiness: `status`, `ready`, `reports_root`(+`_exists`), `auth`, `secure_xml` |
197
+ | `GET /api/version` | `{"name": ..., "version": ...}` from package metadata |
198
+ | `GET /api/docs` | OpenAPI docs (Swagger UI) |
177
199
 
178
200
  The reads (`GET`) and the delete are gated by Airflow RBAC — see below.
179
201
 
@@ -230,6 +252,61 @@ Download them from a report's detail view, or `GET
230
252
  | `AIRFLOW_PYTEST_REPORTS_ROOT` (env) | — | report root (highest precedence) |
231
253
  | `[pytest_reports] reports_root` (cfg) | — | report root |
232
254
  | built-in default | `/opt/airflow/pytest-reports` | fallback |
255
+ | `AIRFLOW_PYTEST_PLUGIN_ENABLE` (env) | `True` | reader on/off — see below |
256
+ | `AIRFLOW_PYTEST_SCAN_CACHE_TTL` (env) | `2.0` | seconds a directory scan is reused (`0` disables) |
257
+ | `AIRFLOW_PYTEST_RETENTION_MAX_AGE_DAYS` (env/cfg) | — | delete runs older than N days |
258
+ | `AIRFLOW_PYTEST_RETENTION_MAX_RUNS` (env/cfg) | — | keep at most N newest runs per dag·task |
259
+ | `AIRFLOW_PYTEST_RETENTION_MAX_TOTAL_MB` (env/cfg) | — | total report-tree budget in MB |
260
+ | `AIRFLOW_PYTEST_FLAKY_WINDOW` (env/cfg) | `30` | default recent runs the flaky detector scans |
261
+ | `AIRFLOW_PYTEST_FLAKY_QUARANTINE_SCORE` (env/cfg) | `0.5` | flakiness score (0–1) that flags a test for quarantine |
262
+ | `AIRFLOW_PYTEST_FLAKY_MIN_SCORE` (env/cfg) | `0.1` | flakiness score (0–1) below which a test is not counted as flaky |
263
+ | `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 |
264
+
265
+ **Enable / disable the reader.** Set `AIRFLOW_PYTEST_PLUGIN_ENABLE` to a falsey
266
+ value (`0`, `false`, `no`, `off`) to stop the plugin registering its UI and API
267
+ with Airflow; any other value, or leaving it unset, keeps it on (the default).
268
+ This is a kill switch for the reader only — the producer-side
269
+ `ArchivingResultParser` still archives reports regardless. It is read once at
270
+ plugin discovery, so toggling it takes effect on the next API-server restart.
271
+
272
+ ```bash
273
+ export AIRFLOW_PYTEST_PLUGIN_ENABLE=false # hide the Pytest Reports UI + API
274
+ ```
275
+
276
+ **Scan cache.** Loading the home page hits several summary-driven endpoints (the run
277
+ list, the flaky panel, the unique-tests count), and the filter box queries as you
278
+ type. To avoid walking the report tree once per call, the filesystem source reuses a
279
+ single scan for `AIRFLOW_PYTEST_SCAN_CACHE_TTL` seconds (default `2.0`; deletes
280
+ invalidate it immediately). New runs therefore appear within a couple of seconds (or
281
+ on **Refresh**); set it to `0` for no caching, or higher on a very large tree.
282
+
283
+ ## Retention (auto-cleanup)
284
+
285
+ Reports accumulate forever unless you prune them. Set any of the
286
+ `AIRFLOW_PYTEST_RETENTION_*` knobs above (all opt-in; unset = keep everything) and
287
+ schedule `prune_reports` from a maintenance DAG:
288
+
289
+ ```python
290
+ from airflow import DAG
291
+ from airflow.providers.standard.operators.python import PythonOperator
292
+ from airflow_pytest_plugin import prune_reports
293
+
294
+ with DAG("pytest_reports_retention", schedule="@daily", catchup=False, ...):
295
+ PythonOperator(task_id="prune", python_callable=prune_reports)
296
+ ```
297
+
298
+ Each knob is a dimension and they combine as a union — a run is deleted if **any**
299
+ applies:
300
+
301
+ - **age** — older than `…_MAX_AGE_DAYS`;
302
+ - **count** — beyond the newest `…_MAX_RUNS` of its dag·task;
303
+ - **size** — oldest-first until the tree fits `…_MAX_TOTAL_MB`.
304
+
305
+ The **newest run of each dag·task is always kept**, so a task's latest result never
306
+ disappears. `prune_reports(dry_run=True)` reports what *would* go without deleting
307
+ (its `RetentionResult` carries `deleted`, `freed_bytes`, `scanned`). Cleanup is
308
+ scheduler-driven — the plugin never deletes on its own. For a custom policy, build a
309
+ `RetentionPolicy(...)` and pass it (`prune_reports(policy)`).
233
310
 
234
311
  ## Architecture (SOLID)
235
312
 
@@ -241,6 +318,7 @@ Mirrors the operator's layering — each piece has one reason to change:
241
318
  | `producer.ArchivingResultParser` | write JUnit XML + `meta.json` (extends the operator's parser) |
242
319
  | `sources.ReportSource` / `FileSystemReportSource` | read/index reports behind an interface (Dependency Inversion) |
243
320
  | `web.create_app` | map HTTP onto a `ReportSource` — knows nothing about the filesystem |
321
+ | `retention` | pure `select_expired` decision + a `prune` orchestrator over any `ReportSource` |
244
322
  | `plugin.PytestReportsPlugin` | register the app with Airflow |
245
323
  | `compat` | the only module that imports Airflow; version differences resolved once |
246
324
  | `models` | JSON-serializable view types; the web layer never sees operator types |
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "airflow-pytest-plugin"
7
- version = "0.2.1"
7
+ version = "0.3.1"
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"
@@ -22,6 +22,7 @@ from .config import get_reports_root
22
22
  from .layout import ReportLayout
23
23
  from .models import CaseView, ReportDetail, ReportRef, ReportSummary
24
24
  from .producer import ArchivingResultParser
25
+ from .retention import RetentionPolicy, RetentionResult, prune_reports
25
26
  from .sources import FileSystemReportSource, ReportSource
26
27
  from .version import __version__ as __version__
27
28
 
@@ -38,6 +39,9 @@ __all__ = [
38
39
  "ReportDetail",
39
40
  "CaseView",
40
41
  "ReportLayout",
42
+ "RetentionPolicy",
43
+ "RetentionResult",
44
+ "prune_reports",
41
45
  "get_reports_root",
42
46
  "create_app",
43
47
  "__version__",
@@ -0,0 +1,177 @@
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
+ """Resolve the report root (env, then Airflow config, then default)."""
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+
21
+ from .compat import get_conf_value
22
+
23
+ ENV_VAR = "AIRFLOW_PYTEST_REPORTS_ROOT"
24
+ CONF_SECTION = "pytest_reports"
25
+ CONF_KEY = "reports_root"
26
+ DEFAULT_ROOT = "/opt/airflow/pytest-reports"
27
+
28
+ #: Toggles whether the reader plugin (UI + API) registers with Airflow. Default on.
29
+ ENABLE_ENV_VAR = "AIRFLOW_PYTEST_PLUGIN_ENABLE"
30
+ _FALSEY = frozenset({"0", "false", "no", "off", "n", "f"})
31
+
32
+
33
+ def is_plugin_enabled() -> bool:
34
+ """Whether the reader plugin should register with Airflow.
35
+
36
+ Reads ``AIRFLOW_PYTEST_PLUGIN_ENABLE`` -- ``True`` (the default when unset/empty)
37
+ registers the UI + API; a falsey value (``0``/``false``/``no``/``off``) disables
38
+ it. Only gates the reader; the producer-side parser is unaffected.
39
+ """
40
+ raw = os.environ.get(ENABLE_ENV_VAR)
41
+ if raw is None or not raw.strip():
42
+ return True
43
+ return raw.strip().lower() not in _FALSEY
44
+
45
+
46
+ #: How long (seconds) the filesystem source may reuse a directory scan. A short
47
+ #: window lets the several summary-driven endpoints on one page load (list + flaky +
48
+ #: unique-tests, plus filter typing) share one tree walk instead of rescanning each.
49
+ SCAN_TTL_ENV_VAR = "AIRFLOW_PYTEST_SCAN_CACHE_TTL"
50
+ DEFAULT_SCAN_TTL = 2.0
51
+
52
+
53
+ def get_scan_cache_ttl() -> float:
54
+ """Resolve the directory-scan cache TTL in seconds (``0`` disables caching).
55
+
56
+ Reads ``AIRFLOW_PYTEST_SCAN_CACHE_TTL``; falls back to ``DEFAULT_SCAN_TTL``.
57
+ A malformed or negative value falls back to the default.
58
+ """
59
+ raw = os.environ.get(SCAN_TTL_ENV_VAR)
60
+ if raw is None or not raw.strip():
61
+ return DEFAULT_SCAN_TTL
62
+ try:
63
+ ttl = float(raw.strip())
64
+ except ValueError:
65
+ return DEFAULT_SCAN_TTL
66
+ return ttl if ttl >= 0 else DEFAULT_SCAN_TTL
67
+
68
+
69
+ #: Retention knobs (all opt-in; unset = keep everything). Each reads its env var,
70
+ #: then the matching ``[pytest_reports]`` cfg key. Non-positive/invalid -> unset.
71
+ RETENTION_MAX_AGE_DAYS_ENV = "AIRFLOW_PYTEST_RETENTION_MAX_AGE_DAYS"
72
+ RETENTION_MAX_RUNS_ENV = "AIRFLOW_PYTEST_RETENTION_MAX_RUNS"
73
+ RETENTION_MAX_TOTAL_MB_ENV = "AIRFLOW_PYTEST_RETENTION_MAX_TOTAL_MB"
74
+
75
+
76
+ def _positive_int_setting(env_var: str, conf_key: str) -> int | None:
77
+ raw = os.environ.get(env_var)
78
+ if raw is None or not raw.strip():
79
+ raw = get_conf_value(CONF_SECTION, conf_key)
80
+ if raw is None or not str(raw).strip():
81
+ return None
82
+ try:
83
+ value = int(str(raw).strip())
84
+ except ValueError:
85
+ return None
86
+ return value if value > 0 else None
87
+
88
+
89
+ def get_retention_max_age_days() -> int | None:
90
+ """Delete runs older than this many days (``None`` = no age limit)."""
91
+ return _positive_int_setting(RETENTION_MAX_AGE_DAYS_ENV, "retention_max_age_days")
92
+
93
+
94
+ def get_retention_max_runs() -> int | None:
95
+ """Keep at most this many newest runs per dag·task (``None`` = unlimited)."""
96
+ return _positive_int_setting(RETENTION_MAX_RUNS_ENV, "retention_max_runs")
97
+
98
+
99
+ def get_retention_max_total_mb() -> int | None:
100
+ """Total report-tree budget in MB (``None`` = unlimited)."""
101
+ return _positive_int_setting(RETENTION_MAX_TOTAL_MB_ENV, "retention_max_total_mb")
102
+
103
+
104
+ #: Flaky-detector defaults (overridable per request via the ``window`` query param).
105
+ FLAKY_WINDOW_ENV = "AIRFLOW_PYTEST_FLAKY_WINDOW"
106
+ DEFAULT_FLAKY_WINDOW = 30
107
+ FLAKY_QUARANTINE_SCORE_ENV = "AIRFLOW_PYTEST_FLAKY_QUARANTINE_SCORE"
108
+ DEFAULT_FLAKY_QUARANTINE_SCORE = 0.5
109
+ #: Flakiness-score floor: below it a test is too steady to count as flaky, so a lone
110
+ #: blip in a long history (a near-zero flip rate) drops off the list.
111
+ FLAKY_MIN_SCORE_ENV = "AIRFLOW_PYTEST_FLAKY_MIN_SCORE"
112
+ DEFAULT_FLAKY_MIN_SCORE = 0.1
113
+
114
+
115
+ def get_flaky_window() -> int:
116
+ """Default number of recent runs the flaky detector looks at per dag·task."""
117
+ return (
118
+ _positive_int_setting(FLAKY_WINDOW_ENV, "flaky_window") or DEFAULT_FLAKY_WINDOW
119
+ )
120
+
121
+
122
+ def _unit_float_setting(env_var: str, conf_key: str, default: float) -> float:
123
+ """A 0–1 float from env, then cfg; default on missing/invalid/out-of-range."""
124
+ raw = os.environ.get(env_var)
125
+ if raw is None or not raw.strip():
126
+ raw = get_conf_value(CONF_SECTION, conf_key)
127
+ if raw is None or not str(raw).strip():
128
+ return default
129
+ try:
130
+ value = float(str(raw).strip())
131
+ except ValueError:
132
+ return default
133
+ return value if 0.0 <= value <= 1.0 else default
134
+
135
+
136
+ def get_flaky_quarantine_score() -> float:
137
+ """Flakiness score (0–1) at/above which a test is flagged for quarantine."""
138
+ return _unit_float_setting(
139
+ FLAKY_QUARANTINE_SCORE_ENV,
140
+ "flaky_quarantine_score",
141
+ DEFAULT_FLAKY_QUARANTINE_SCORE,
142
+ )
143
+
144
+
145
+ def get_flaky_min_score() -> float:
146
+ """Flakiness score (0–1) below which a test is NOT counted as flaky."""
147
+ return _unit_float_setting(
148
+ FLAKY_MIN_SCORE_ENV, "flaky_min_score", DEFAULT_FLAKY_MIN_SCORE
149
+ )
150
+
151
+
152
+ #: Pass-rate (0–1) at/above which a run counts as successful ("Passing runs"). The
153
+ #: rate is over executed tests, so a run can carry a few failures and still pass.
154
+ #: At 1.0 a run is successful only with zero failures/errors (the strict default of
155
+ #: older versions). 0.85 is a common "good enough" bar (ISTQB defines no fixed number).
156
+ SUCCESS_THRESHOLD_ENV = "AIRFLOW_PYTEST_SUCCESS_THRESHOLD"
157
+ DEFAULT_SUCCESS_THRESHOLD = 0.85
158
+
159
+
160
+ def get_success_threshold() -> float:
161
+ """Pass-rate (0–1) at/above which a run counts as successful."""
162
+ return _unit_float_setting(
163
+ SUCCESS_THRESHOLD_ENV, "success_threshold", DEFAULT_SUCCESS_THRESHOLD
164
+ )
165
+
166
+
167
+ def get_reports_root() -> str:
168
+ """Resolve the report root directory (absolute path)."""
169
+ env = os.environ.get(ENV_VAR)
170
+ if env and env.strip():
171
+ return os.path.abspath(env.strip())
172
+
173
+ conf = get_conf_value(CONF_SECTION, CONF_KEY)
174
+ if conf and conf.strip():
175
+ return os.path.abspath(conf.strip())
176
+
177
+ return os.path.abspath(DEFAULT_ROOT)
@@ -117,6 +117,19 @@ class ReportSummary:
117
117
  }
118
118
 
119
119
 
120
+ def run_succeeds(passed: int, failed: int, errors: int, threshold: float) -> bool:
121
+ """Whether a run counts as successful at a given pass-rate ``threshold`` (0–1).
122
+
123
+ The rate is over *executed* tests -- ``passed / (passed + failed + errors)`` --
124
+ so skipped tests neither help nor hurt; a run with nothing executed is a pass.
125
+ At ``threshold == 1.0`` this is exactly "no failures or errors".
126
+ """
127
+ executed = passed + failed + errors
128
+ if executed <= 0:
129
+ return True
130
+ return passed / executed >= threshold
131
+
132
+
120
133
  @dataclass(frozen=True)
121
134
  class CaseView:
122
135
  """One test case, flattened for the detail table."""
@@ -20,6 +20,7 @@ import logging
20
20
  from typing import TYPE_CHECKING, Any
21
21
 
22
22
  from .compat import get_airflow_plugin_base
23
+ from .config import ENABLE_ENV_VAR, is_plugin_enabled
23
24
 
24
25
  _log = logging.getLogger(__name__)
25
26
 
@@ -73,9 +74,16 @@ else:
73
74
  _Base = object
74
75
 
75
76
 
77
+ #: Kill switch: when AIRFLOW_PYTEST_PLUGIN_ENABLE is falsey the reader registers
78
+ #: nothing (no app, no nav link), so the UI/API are unavailable.
79
+ _ENABLED = is_plugin_enabled()
80
+ if not _ENABLED:
81
+ _log.info("Pytest Reports reader disabled via %s; not registering.", ENABLE_ENV_VAR)
82
+
83
+
76
84
  class PytestReportsPlugin(_Base):
77
- """Exposes the Pytest Reports UI to Airflow."""
85
+ """Exposes the Pytest Reports UI to Airflow (unless disabled via the env var)."""
78
86
 
79
87
  name = "pytest_reports"
80
- fastapi_apps = _build_fastapi_apps()
81
- external_views = _build_external_views()
88
+ fastapi_apps = _build_fastapi_apps() if _ENABLED else []
89
+ external_views = _build_external_views() if _ENABLED else []
@@ -163,6 +163,9 @@ class ArchivingResultParser(JUnitResultParser): # type: ignore[misc]
163
163
  "report_file": REPORT_FILENAME,
164
164
  "allure": self._archive_allure(out_dir, ref),
165
165
  "summary": result.to_xcom(),
166
+ # Compact per-test outcomes [node_id, outcome, duration] so cross-run
167
+ # views (compare/flaky/history) need not re-parse junit.xml.
168
+ "tests": _test_rows(result),
166
169
  }
167
170
  # Atomic write: a reader scanning concurrently never sees a half file.
168
171
  tmp = os.path.join(out_dir, f".{META_FILENAME}.{uuid.uuid4().hex}.tmp")
@@ -229,6 +232,18 @@ def _first_int(*values: Any, default: int) -> int:
229
232
  return default
230
233
 
231
234
 
235
+ def _test_rows(result: TestRunResult) -> list[list[Any]]:
236
+ """Compact ``[node_id, outcome, duration]`` rows for cross-run views."""
237
+ rows: list[list[Any]] = []
238
+ for c in getattr(result, "cases", ()) or ():
239
+ try:
240
+ dur = round(float(getattr(c, "time", 0.0) or 0.0), 3)
241
+ except (TypeError, ValueError):
242
+ dur = 0.0
243
+ rows.append([c.node_id, c.outcome, dur])
244
+ return rows
245
+
246
+
232
247
  def _logical_date(context: dict[str, Any] | None) -> str | None:
233
248
  if not context:
234
249
  return None