airflow-pytest-plugin 0.3.0__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 (47) hide show
  1. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/PKG-INFO +47 -8
  2. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/README.md +46 -7
  3. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/pyproject.toml +1 -1
  4. {airflow_pytest_plugin-0.3.0 → 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.3.0 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/models.py +13 -0
  7. airflow_pytest_plugin-0.3.1/src/airflow_pytest_plugin/retention.py +253 -0
  8. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/sources/base.py +8 -0
  9. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/sources/filesystem.py +41 -11
  10. airflow_pytest_plugin-0.3.1/src/airflow_pytest_plugin/web/routes/flaky.py +162 -0
  11. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/web/routes/reports.py +9 -2
  12. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/web/templates.py +274 -41
  13. airflow_pytest_plugin-0.3.1/tests/test_config.py +145 -0
  14. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/tests/test_filesystem_source.py +43 -0
  15. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/tests/test_models.py +23 -0
  16. airflow_pytest_plugin-0.3.1/tests/test_retention.py +287 -0
  17. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/tests/test_web_app.py +112 -1
  18. airflow_pytest_plugin-0.3.0/src/airflow_pytest_plugin/config.py +0 -79
  19. airflow_pytest_plugin-0.3.0/src/airflow_pytest_plugin/web/routes/flaky.py +0 -106
  20. airflow_pytest_plugin-0.3.0/tests/test_config.py +0 -82
  21. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/.gitignore +0 -0
  22. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/LICENSE +0 -0
  23. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/examples/example_dag.py +0 -0
  24. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/compat/__init__.py +0 -0
  25. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/compat/airflow.py +0 -0
  26. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/layout.py +0 -0
  27. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/plugin.py +0 -0
  28. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/producer/__init__.py +0 -0
  29. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/producer/archiving_parser.py +0 -0
  30. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/sources/__init__.py +0 -0
  31. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/version.py +0 -0
  32. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/web/__init__.py +0 -0
  33. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/web/__main__.py +0 -0
  34. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/web/app.py +0 -0
  35. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/web/routes/__init__.py +0 -0
  36. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/web/routes/common.py +0 -0
  37. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/web/routes/compare.py +0 -0
  38. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/web/routes/failures.py +0 -0
  39. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/web/routes/monitoring.py +0 -0
  40. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/tests/conftest.py +0 -0
  41. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/tests/test_archiving_parser.py +0 -0
  42. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/tests/test_authz_integration.py +0 -0
  43. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/tests/test_compat.py +0 -0
  44. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/tests/test_layout.py +0 -0
  45. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/tests/test_package.py +0 -0
  46. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/tests/test_plugin.py +0 -0
  47. {airflow_pytest_plugin-0.3.0 → 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.3.0
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
@@ -98,9 +98,11 @@ It has two halves that share one on-disk layout:
98
98
  ## Screenshots
99
99
 
100
100
  **Overview** — the run list with the historical chart (per-status legend
101
- toggles, run numbers, and a carousel beyond 30 runs) beside a **flaky-tests
102
- panel**, KPI cards (including a clickable **unique tests** count), and Airflow-
103
- 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:
104
106
 
105
107
  ![Pytest Reports — overview](https://raw.githubusercontent.com/IKrysanov/airflow-pytest-plugin/main/docs/screenshots/overview.png)
106
108
 
@@ -112,9 +114,10 @@ output on expand:
112
114
  ![Pytest Reports — a single run](https://raw.githubusercontent.com/IKrysanov/airflow-pytest-plugin/main/docs/screenshots/detail.png)
113
115
 
114
116
  **Flaky tests & comparison** — from a run, *Flaky tests* lists the tests that
115
- both pass and fail across recent runs (with a recent-outcome strip), and
116
- *Compare to previous* diffs it against the prior run; expanding a case offers its
117
- full **history**:
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**:
118
121
 
119
122
  ![Pytest Reports — flaky tests](https://raw.githubusercontent.com/IKrysanov/airflow-pytest-plugin/main/docs/screenshots/flaky.png)
120
123
 
@@ -229,7 +232,7 @@ runtime. Endpoints (relative to the mount):
229
232
  | `GET /api/reports/{report_id}` | one report with per-case rows |
230
233
  | `GET /api/failures?dag_id=&run_id=&task_id=` | failed/errored cases across the visible runs |
231
234
  | `GET /api/compare?base=&head=` | per-test diff between two runs (newly failed / fixed / …) |
232
- | `GET /api/flaky?dag_id=&task_id=&window=` | tests that both pass and fail over recent runs |
235
+ | `GET /api/flaky?dag_id=&task_id=&window=` | flaky tests with score, trend, and a quarantine flag |
233
236
  | `GET /api/test-history?dag_id=&task_id=&node_id=&limit=` | one test's outcome per run |
234
237
  | `GET /api/unique-tests?dag_id=&task_id=&run_id=&full=` | distinct test count (+ list when `full`) |
235
238
  | `DELETE /api/reports/{report_id}` | delete a report (RBAC-gated) |
@@ -295,6 +298,13 @@ Download them from a report's detail view, or `GET
295
298
  | built-in default | `/opt/airflow/pytest-reports` | fallback |
296
299
  | `AIRFLOW_PYTEST_PLUGIN_ENABLE` (env) | `True` | reader on/off — see below |
297
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 |
298
308
 
299
309
  **Enable / disable the reader.** Set `AIRFLOW_PYTEST_PLUGIN_ENABLE` to a falsey
300
310
  value (`0`, `false`, `no`, `off`) to stop the plugin registering its UI and API
@@ -314,6 +324,34 @@ single scan for `AIRFLOW_PYTEST_SCAN_CACHE_TTL` seconds (default `2.0`; deletes
314
324
  invalidate it immediately). New runs therefore appear within a couple of seconds (or
315
325
  on **Refresh**); set it to `0` for no caching, or higher on a very large tree.
316
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)`).
354
+
317
355
  ## Architecture (SOLID)
318
356
 
319
357
  Mirrors the operator's layering — each piece has one reason to change:
@@ -324,6 +362,7 @@ Mirrors the operator's layering — each piece has one reason to change:
324
362
  | `producer.ArchivingResultParser` | write JUnit XML + `meta.json` (extends the operator's parser) |
325
363
  | `sources.ReportSource` / `FileSystemReportSource` | read/index reports behind an interface (Dependency Inversion) |
326
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` |
327
366
  | `plugin.PytestReportsPlugin` | register the app with Airflow |
328
367
  | `compat` | the only module that imports Airflow; version differences resolved once |
329
368
  | `models` | JSON-serializable view types; the web layer never sees operator types |
@@ -54,9 +54,11 @@ It has two halves that share one on-disk layout:
54
54
  ## Screenshots
55
55
 
56
56
  **Overview** — the run list with the historical chart (per-status legend
57
- toggles, run numbers, and a carousel beyond 30 runs) beside a **flaky-tests
58
- panel**, KPI cards (including a clickable **unique tests** count), and Airflow-
59
- 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:
60
62
 
61
63
  ![Pytest Reports — overview](https://raw.githubusercontent.com/IKrysanov/airflow-pytest-plugin/main/docs/screenshots/overview.png)
62
64
 
@@ -68,9 +70,10 @@ output on expand:
68
70
  ![Pytest Reports — a single run](https://raw.githubusercontent.com/IKrysanov/airflow-pytest-plugin/main/docs/screenshots/detail.png)
69
71
 
70
72
  **Flaky tests & comparison** — from a run, *Flaky tests* lists the tests that
71
- both pass and fail across recent runs (with a recent-outcome strip), and
72
- *Compare to previous* diffs it against the prior run; expanding a case offers its
73
- full **history**:
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**:
74
77
 
75
78
  ![Pytest Reports — flaky tests](https://raw.githubusercontent.com/IKrysanov/airflow-pytest-plugin/main/docs/screenshots/flaky.png)
76
79
 
@@ -185,7 +188,7 @@ runtime. Endpoints (relative to the mount):
185
188
  | `GET /api/reports/{report_id}` | one report with per-case rows |
186
189
  | `GET /api/failures?dag_id=&run_id=&task_id=` | failed/errored cases across the visible runs |
187
190
  | `GET /api/compare?base=&head=` | per-test diff between two runs (newly failed / fixed / …) |
188
- | `GET /api/flaky?dag_id=&task_id=&window=` | tests that both pass and fail over recent runs |
191
+ | `GET /api/flaky?dag_id=&task_id=&window=` | flaky tests with score, trend, and a quarantine flag |
189
192
  | `GET /api/test-history?dag_id=&task_id=&node_id=&limit=` | one test's outcome per run |
190
193
  | `GET /api/unique-tests?dag_id=&task_id=&run_id=&full=` | distinct test count (+ list when `full`) |
191
194
  | `DELETE /api/reports/{report_id}` | delete a report (RBAC-gated) |
@@ -251,6 +254,13 @@ Download them from a report's detail view, or `GET
251
254
  | built-in default | `/opt/airflow/pytest-reports` | fallback |
252
255
  | `AIRFLOW_PYTEST_PLUGIN_ENABLE` (env) | `True` | reader on/off — see below |
253
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 |
254
264
 
255
265
  **Enable / disable the reader.** Set `AIRFLOW_PYTEST_PLUGIN_ENABLE` to a falsey
256
266
  value (`0`, `false`, `no`, `off`) to stop the plugin registering its UI and API
@@ -270,6 +280,34 @@ single scan for `AIRFLOW_PYTEST_SCAN_CACHE_TTL` seconds (default `2.0`; deletes
270
280
  invalidate it immediately). New runs therefore appear within a couple of seconds (or
271
281
  on **Refresh**); set it to `0` for no caching, or higher on a very large tree.
272
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)`).
310
+
273
311
  ## Architecture (SOLID)
274
312
 
275
313
  Mirrors the operator's layering — each piece has one reason to change:
@@ -280,6 +318,7 @@ Mirrors the operator's layering — each piece has one reason to change:
280
318
  | `producer.ArchivingResultParser` | write JUnit XML + `meta.json` (extends the operator's parser) |
281
319
  | `sources.ReportSource` / `FileSystemReportSource` | read/index reports behind an interface (Dependency Inversion) |
282
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` |
283
322
  | `plugin.PytestReportsPlugin` | register the app with Airflow |
284
323
  | `compat` | the only module that imports Airflow; version differences resolved once |
285
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.3.0"
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."""
@@ -0,0 +1,253 @@
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
+ """Retention: choose and delete old archived runs by age / count / size.
16
+
17
+ Layered so the decision is pure and testable, separate from the I/O:
18
+
19
+ - ``RetentionPolicy`` — the value object (limits, ``from_config``).
20
+ - ``select_expired`` — pure: given run facts + a policy, which runs to delete.
21
+ - ``prune`` — orchestrator: list + measure via a ``ReportSource``, then delete.
22
+
23
+ Across every policy the **newest run of each dag·task is always kept**, so a task's
24
+ latest result never disappears.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import logging
30
+ from dataclasses import dataclass
31
+ from datetime import datetime, timedelta, timezone
32
+ from typing import TYPE_CHECKING, Any
33
+
34
+ from .config import (
35
+ get_retention_max_age_days,
36
+ get_retention_max_runs,
37
+ get_retention_max_total_mb,
38
+ )
39
+ from .models import ReportRef
40
+
41
+ if TYPE_CHECKING:
42
+ from .sources import ReportSource
43
+
44
+ _log = logging.getLogger(__name__)
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class RetentionPolicy:
49
+ """Limits for retention; ``None`` on a field means that dimension is unbounded."""
50
+
51
+ max_age_days: int | None = None
52
+ max_runs_per_task: int | None = None
53
+ max_total_bytes: int | None = None
54
+
55
+ def __post_init__(self) -> None:
56
+ # A non-positive limit is nonsensical and would break the keep-newest
57
+ # invariant (e.g. max_runs_per_task=0 would mark a group's newest run).
58
+ for name in ("max_age_days", "max_runs_per_task", "max_total_bytes"):
59
+ value = getattr(self, name)
60
+ if value is not None and value <= 0:
61
+ raise ValueError(
62
+ f"{name} must be a positive int or None, got {value!r}"
63
+ )
64
+
65
+ @property
66
+ def is_active(self) -> bool:
67
+ """Whether any limit is set (an inactive policy deletes nothing)."""
68
+ return any(
69
+ v is not None
70
+ for v in (self.max_age_days, self.max_runs_per_task, self.max_total_bytes)
71
+ )
72
+
73
+ @property
74
+ def needs_sizes(self) -> bool:
75
+ """Whether evaluating it requires measuring report sizes (the size policy)."""
76
+ return self.max_total_bytes is not None
77
+
78
+ @classmethod
79
+ def from_config(cls) -> RetentionPolicy:
80
+ """Build from the env vars / Airflow cfg (all opt-in; default = keep all)."""
81
+ mb = get_retention_max_total_mb()
82
+ return cls(
83
+ max_age_days=get_retention_max_age_days(),
84
+ max_runs_per_task=get_retention_max_runs(),
85
+ max_total_bytes=mb * 1024 * 1024 if mb is not None else None,
86
+ )
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class RunEntry:
91
+ """The facts ``select_expired`` needs about one run -- no I/O."""
92
+
93
+ ref: ReportRef
94
+ created_at: str | None # ISO-8601, as stored in meta.json
95
+ size: int = 0 # bytes; 0 unless the size policy needs it
96
+
97
+
98
+ @dataclass(frozen=True)
99
+ class RetentionResult:
100
+ """What a prune did (or would do, under ``dry_run``)."""
101
+
102
+ deleted: tuple[str, ...] # tokens of the deleted (or would-be-deleted) runs
103
+ freed_bytes: int
104
+ scanned: int
105
+ dry_run: bool
106
+
107
+ @property
108
+ def deleted_count(self) -> int:
109
+ return len(self.deleted)
110
+
111
+ def to_dict(self) -> dict[str, Any]:
112
+ return {
113
+ "deleted": list(self.deleted),
114
+ "deleted_count": self.deleted_count,
115
+ "freed_bytes": self.freed_bytes,
116
+ "scanned": self.scanned,
117
+ "dry_run": self.dry_run,
118
+ }
119
+
120
+
121
+ def _parse_dt(value: str | None) -> datetime | None:
122
+ """Parse an ISO timestamp to an aware datetime (assume UTC if naive), else None."""
123
+ if not value:
124
+ return None
125
+ try:
126
+ dt = datetime.fromisoformat(value)
127
+ except ValueError:
128
+ return None
129
+ return dt if dt.tzinfo is not None else dt.replace(tzinfo=timezone.utc)
130
+
131
+
132
+ def select_expired(
133
+ entries: list[RunEntry], policy: RetentionPolicy, *, now: datetime
134
+ ) -> list[ReportRef]:
135
+ """Pure decision: which runs to delete under ``policy`` as of ``now``.
136
+
137
+ The newest run of each dag·task is never selected. Age and count act per
138
+ dag·task; size trims oldest-first across all tasks until the tree fits the
139
+ budget. The dimensions combine as a union.
140
+ """
141
+ if not policy.is_active:
142
+ return []
143
+
144
+ groups: dict[tuple[str, str], list[RunEntry]] = {}
145
+ for entry in entries:
146
+ groups.setdefault((entry.ref.dag_id, entry.ref.task_id), []).append(entry)
147
+ for group in groups.values():
148
+ group.sort(key=lambda e: e.created_at or "", reverse=True) # newest first
149
+
150
+ marked: dict[str, RunEntry] = {} # token -> entry (dedup, insertion-ordered)
151
+
152
+ def mark(entry: RunEntry) -> None:
153
+ marked.setdefault(entry.ref.token, entry)
154
+
155
+ # Count: keep the newest N of each dag·task.
156
+ if policy.max_runs_per_task is not None:
157
+ for group in groups.values():
158
+ for entry in group[policy.max_runs_per_task :]:
159
+ mark(entry)
160
+
161
+ # Age: drop runs older than the cutoff -- but never a group's newest (group[1:]).
162
+ if policy.max_age_days is not None:
163
+ cutoff = now - timedelta(days=policy.max_age_days)
164
+ for group in groups.values():
165
+ for entry in group[1:]:
166
+ dt = _parse_dt(entry.created_at)
167
+ if dt is not None and dt < cutoff:
168
+ mark(entry)
169
+
170
+ # Size: if the tree still exceeds the budget, delete oldest-first (never a
171
+ # group's newest) until it fits or only protected runs remain.
172
+ if policy.max_total_bytes is not None:
173
+ remaining = sum(e.size for e in entries) - sum(e.size for e in marked.values())
174
+ if remaining > policy.max_total_bytes:
175
+ candidates = [e for group in groups.values() for e in group[1:]]
176
+ candidates.sort(key=lambda e: e.created_at or "") # oldest first
177
+ for entry in candidates:
178
+ if remaining <= policy.max_total_bytes:
179
+ break
180
+ if entry.ref.token in marked:
181
+ continue
182
+ mark(entry)
183
+ remaining -= entry.size
184
+
185
+ return [entry.ref for entry in marked.values()]
186
+
187
+
188
+ def prune(
189
+ source: ReportSource,
190
+ policy: RetentionPolicy | None = None,
191
+ *,
192
+ now: datetime | None = None,
193
+ dry_run: bool = False,
194
+ ) -> RetentionResult:
195
+ """Apply ``policy`` (default: from config) to ``source``, deleting expired runs.
196
+
197
+ With ``dry_run`` nothing is deleted -- the result still lists what would go.
198
+ Pass ``now`` for deterministic age handling (defaults to the current UTC time).
199
+ """
200
+ resolved = policy if policy is not None else RetentionPolicy.from_config()
201
+ if not resolved.is_active:
202
+ return RetentionResult(deleted=(), freed_bytes=0, scanned=0, dry_run=dry_run)
203
+
204
+ entries = [
205
+ RunEntry(
206
+ ref=s.ref,
207
+ created_at=s.created_at,
208
+ size=source.report_size(s.ref) if resolved.needs_sizes else 0,
209
+ )
210
+ for s in source.list_summaries()
211
+ ]
212
+ when = now if now is not None else datetime.now(timezone.utc)
213
+ to_delete = select_expired(entries, resolved, now=when)
214
+
215
+ sizes = {e.ref.token: e.size for e in entries}
216
+ freed = sum(sizes.get(ref.token, 0) for ref in to_delete)
217
+ if not dry_run:
218
+ for ref in to_delete:
219
+ source.delete(ref)
220
+
221
+ _log.info(
222
+ "retention: %s %d of %d run(s), %d bytes (dry_run=%s)",
223
+ "would delete" if dry_run else "deleted",
224
+ len(to_delete),
225
+ len(entries),
226
+ freed,
227
+ dry_run,
228
+ )
229
+ return RetentionResult(
230
+ deleted=tuple(ref.token for ref in to_delete),
231
+ freed_bytes=freed,
232
+ scanned=len(entries),
233
+ dry_run=dry_run,
234
+ )
235
+
236
+
237
+ def prune_reports(
238
+ policy: RetentionPolicy | None = None,
239
+ *,
240
+ source: ReportSource | None = None,
241
+ dry_run: bool = False,
242
+ now: datetime | None = None,
243
+ ) -> RetentionResult:
244
+ """Entry point for a scheduled maintenance task (e.g. a ``PythonOperator``).
245
+
246
+ Defaults to the filesystem source and the policy from config -- so a bare
247
+ ``prune_reports`` callable Just Works once the env/cfg limits are set.
248
+ """
249
+ if source is None:
250
+ from .sources import FileSystemReportSource
251
+
252
+ source = FileSystemReportSource()
253
+ return prune(source, policy, now=now, dry_run=dry_run)