airflow-pytest-plugin 0.3.0__tar.gz → 0.3.2__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.2}/PKG-INFO +51 -8
  2. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.2}/README.md +50 -7
  3. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.2}/pyproject.toml +1 -1
  4. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.2}/src/airflow_pytest_plugin/__init__.py +4 -0
  5. airflow_pytest_plugin-0.3.2/src/airflow_pytest_plugin/config.py +177 -0
  6. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.2}/src/airflow_pytest_plugin/models.py +13 -0
  7. airflow_pytest_plugin-0.3.2/src/airflow_pytest_plugin/retention.py +253 -0
  8. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.2}/src/airflow_pytest_plugin/sources/base.py +8 -0
  9. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.2}/src/airflow_pytest_plugin/sources/filesystem.py +41 -11
  10. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.2}/src/airflow_pytest_plugin/web/routes/common.py +35 -0
  11. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.2}/src/airflow_pytest_plugin/web/routes/compare.py +42 -2
  12. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.2}/src/airflow_pytest_plugin/web/routes/failures.py +22 -2
  13. airflow_pytest_plugin-0.3.2/src/airflow_pytest_plugin/web/routes/flaky.py +188 -0
  14. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.2}/src/airflow_pytest_plugin/web/routes/monitoring.py +20 -3
  15. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.2}/src/airflow_pytest_plugin/web/routes/reports.py +189 -9
  16. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.2}/src/airflow_pytest_plugin/web/templates.py +537 -77
  17. airflow_pytest_plugin-0.3.2/tests/test_config.py +145 -0
  18. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.2}/tests/test_filesystem_source.py +43 -0
  19. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.2}/tests/test_models.py +23 -0
  20. airflow_pytest_plugin-0.3.2/tests/test_retention.py +287 -0
  21. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.2}/tests/test_web_app.py +206 -1
  22. airflow_pytest_plugin-0.3.0/src/airflow_pytest_plugin/config.py +0 -79
  23. airflow_pytest_plugin-0.3.0/src/airflow_pytest_plugin/web/routes/flaky.py +0 -106
  24. airflow_pytest_plugin-0.3.0/tests/test_config.py +0 -82
  25. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.2}/.gitignore +0 -0
  26. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.2}/LICENSE +0 -0
  27. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.2}/examples/example_dag.py +0 -0
  28. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.2}/src/airflow_pytest_plugin/compat/__init__.py +0 -0
  29. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.2}/src/airflow_pytest_plugin/compat/airflow.py +0 -0
  30. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.2}/src/airflow_pytest_plugin/layout.py +0 -0
  31. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.2}/src/airflow_pytest_plugin/plugin.py +0 -0
  32. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.2}/src/airflow_pytest_plugin/producer/__init__.py +0 -0
  33. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.2}/src/airflow_pytest_plugin/producer/archiving_parser.py +0 -0
  34. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.2}/src/airflow_pytest_plugin/sources/__init__.py +0 -0
  35. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.2}/src/airflow_pytest_plugin/version.py +0 -0
  36. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.2}/src/airflow_pytest_plugin/web/__init__.py +0 -0
  37. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.2}/src/airflow_pytest_plugin/web/__main__.py +0 -0
  38. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.2}/src/airflow_pytest_plugin/web/app.py +0 -0
  39. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.2}/src/airflow_pytest_plugin/web/routes/__init__.py +0 -0
  40. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.2}/tests/conftest.py +0 -0
  41. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.2}/tests/test_archiving_parser.py +0 -0
  42. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.2}/tests/test_authz_integration.py +0 -0
  43. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.2}/tests/test_compat.py +0 -0
  44. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.2}/tests/test_layout.py +0 -0
  45. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.2}/tests/test_package.py +0 -0
  46. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.2}/tests/test_plugin.py +0 -0
  47. {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.2}/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.2
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,14 @@ 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. The run list is **grouped by
106
+ dag·task** by default (a checkbox toggles the flat list) — collapsible groups with
107
+ run count, pass-rate, average duration and last status, each sortable on its own;
108
+ select a whole group to chart its trend:
104
109
 
105
110
  ![Pytest Reports — overview](https://raw.githubusercontent.com/IKrysanov/airflow-pytest-plugin/main/docs/screenshots/overview.png)
106
111
 
@@ -112,9 +117,10 @@ output on expand:
112
117
  ![Pytest Reports — a single run](https://raw.githubusercontent.com/IKrysanov/airflow-pytest-plugin/main/docs/screenshots/detail.png)
113
118
 
114
119
  **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**:
120
+ both pass and fail across recent runs, each with a recent-outcome strip, a
121
+ **flakiness score**, a **trend** arrow, and a **quarantine** badge, over a
122
+ configurable analysis window; *Compare to previous* diffs it against the prior
123
+ run; expanding a case offers its full **history**:
118
124
 
119
125
  ![Pytest Reports — flaky tests](https://raw.githubusercontent.com/IKrysanov/airflow-pytest-plugin/main/docs/screenshots/flaky.png)
120
126
 
@@ -227,9 +233,10 @@ runtime. Endpoints (relative to the mount):
227
233
  | `GET /` | the single-page viewer (HTML) |
228
234
  | `GET /api/reports?dag_id=&run_id=` | summaries, newest first |
229
235
  | `GET /api/reports/{report_id}` | one report with per-case rows |
236
+ | `GET /api/groups?dag_id=&task_id=` | runs aggregated by dag·task (count, pass-rate, avg duration, last status) |
230
237
  | `GET /api/failures?dag_id=&run_id=&task_id=` | failed/errored cases across the visible runs |
231
238
  | `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 |
239
+ | `GET /api/flaky?dag_id=&task_id=&window=` | flaky tests with score, trend, and a quarantine flag |
233
240
  | `GET /api/test-history?dag_id=&task_id=&node_id=&limit=` | one test's outcome per run |
234
241
  | `GET /api/unique-tests?dag_id=&task_id=&run_id=&full=` | distinct test count (+ list when `full`) |
235
242
  | `DELETE /api/reports/{report_id}` | delete a report (RBAC-gated) |
@@ -295,6 +302,13 @@ Download them from a report's detail view, or `GET
295
302
  | built-in default | `/opt/airflow/pytest-reports` | fallback |
296
303
  | `AIRFLOW_PYTEST_PLUGIN_ENABLE` (env) | `True` | reader on/off — see below |
297
304
  | `AIRFLOW_PYTEST_SCAN_CACHE_TTL` (env) | `2.0` | seconds a directory scan is reused (`0` disables) |
305
+ | `AIRFLOW_PYTEST_RETENTION_MAX_AGE_DAYS` (env/cfg) | — | delete runs older than N days |
306
+ | `AIRFLOW_PYTEST_RETENTION_MAX_RUNS` (env/cfg) | — | keep at most N newest runs per dag·task |
307
+ | `AIRFLOW_PYTEST_RETENTION_MAX_TOTAL_MB` (env/cfg) | — | total report-tree budget in MB |
308
+ | `AIRFLOW_PYTEST_FLAKY_WINDOW` (env/cfg) | `30` | default recent runs the flaky detector scans |
309
+ | `AIRFLOW_PYTEST_FLAKY_QUARANTINE_SCORE` (env/cfg) | `0.5` | flakiness score (0–1) that flags a test for quarantine |
310
+ | `AIRFLOW_PYTEST_FLAKY_MIN_SCORE` (env/cfg) | `0.1` | flakiness score (0–1) below which a test is not counted as flaky |
311
+ | `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
312
 
299
313
  **Enable / disable the reader.** Set `AIRFLOW_PYTEST_PLUGIN_ENABLE` to a falsey
300
314
  value (`0`, `false`, `no`, `off`) to stop the plugin registering its UI and API
@@ -314,6 +328,34 @@ single scan for `AIRFLOW_PYTEST_SCAN_CACHE_TTL` seconds (default `2.0`; deletes
314
328
  invalidate it immediately). New runs therefore appear within a couple of seconds (or
315
329
  on **Refresh**); set it to `0` for no caching, or higher on a very large tree.
316
330
 
331
+ ## Retention (auto-cleanup)
332
+
333
+ Reports accumulate forever unless you prune them. Set any of the
334
+ `AIRFLOW_PYTEST_RETENTION_*` knobs above (all opt-in; unset = keep everything) and
335
+ schedule `prune_reports` from a maintenance DAG:
336
+
337
+ ```python
338
+ from airflow import DAG
339
+ from airflow.providers.standard.operators.python import PythonOperator
340
+ from airflow_pytest_plugin import prune_reports
341
+
342
+ with DAG("pytest_reports_retention", schedule="@daily", catchup=False, ...):
343
+ PythonOperator(task_id="prune", python_callable=prune_reports)
344
+ ```
345
+
346
+ Each knob is a dimension and they combine as a union — a run is deleted if **any**
347
+ applies:
348
+
349
+ - **age** — older than `…_MAX_AGE_DAYS`;
350
+ - **count** — beyond the newest `…_MAX_RUNS` of its dag·task;
351
+ - **size** — oldest-first until the tree fits `…_MAX_TOTAL_MB`.
352
+
353
+ The **newest run of each dag·task is always kept**, so a task's latest result never
354
+ disappears. `prune_reports(dry_run=True)` reports what *would* go without deleting
355
+ (its `RetentionResult` carries `deleted`, `freed_bytes`, `scanned`). Cleanup is
356
+ scheduler-driven — the plugin never deletes on its own. For a custom policy, build a
357
+ `RetentionPolicy(...)` and pass it (`prune_reports(policy)`).
358
+
317
359
  ## Architecture (SOLID)
318
360
 
319
361
  Mirrors the operator's layering — each piece has one reason to change:
@@ -324,6 +366,7 @@ Mirrors the operator's layering — each piece has one reason to change:
324
366
  | `producer.ArchivingResultParser` | write JUnit XML + `meta.json` (extends the operator's parser) |
325
367
  | `sources.ReportSource` / `FileSystemReportSource` | read/index reports behind an interface (Dependency Inversion) |
326
368
  | `web.create_app` | map HTTP onto a `ReportSource` — knows nothing about the filesystem |
369
+ | `retention` | pure `select_expired` decision + a `prune` orchestrator over any `ReportSource` |
327
370
  | `plugin.PytestReportsPlugin` | register the app with Airflow |
328
371
  | `compat` | the only module that imports Airflow; version differences resolved once |
329
372
  | `models` | JSON-serializable view types; the web layer never sees operator types |
@@ -54,9 +54,14 @@ 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. The run list is **grouped by
62
+ dag·task** by default (a checkbox toggles the flat list) — collapsible groups with
63
+ run count, pass-rate, average duration and last status, each sortable on its own;
64
+ select a whole group to chart its trend:
60
65
 
61
66
  ![Pytest Reports — overview](https://raw.githubusercontent.com/IKrysanov/airflow-pytest-plugin/main/docs/screenshots/overview.png)
62
67
 
@@ -68,9 +73,10 @@ output on expand:
68
73
  ![Pytest Reports — a single run](https://raw.githubusercontent.com/IKrysanov/airflow-pytest-plugin/main/docs/screenshots/detail.png)
69
74
 
70
75
  **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**:
76
+ both pass and fail across recent runs, each with a recent-outcome strip, a
77
+ **flakiness score**, a **trend** arrow, and a **quarantine** badge, over a
78
+ configurable analysis window; *Compare to previous* diffs it against the prior
79
+ run; expanding a case offers its full **history**:
74
80
 
75
81
  ![Pytest Reports — flaky tests](https://raw.githubusercontent.com/IKrysanov/airflow-pytest-plugin/main/docs/screenshots/flaky.png)
76
82
 
@@ -183,9 +189,10 @@ runtime. Endpoints (relative to the mount):
183
189
  | `GET /` | the single-page viewer (HTML) |
184
190
  | `GET /api/reports?dag_id=&run_id=` | summaries, newest first |
185
191
  | `GET /api/reports/{report_id}` | one report with per-case rows |
192
+ | `GET /api/groups?dag_id=&task_id=` | runs aggregated by dag·task (count, pass-rate, avg duration, last status) |
186
193
  | `GET /api/failures?dag_id=&run_id=&task_id=` | failed/errored cases across the visible runs |
187
194
  | `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 |
195
+ | `GET /api/flaky?dag_id=&task_id=&window=` | flaky tests with score, trend, and a quarantine flag |
189
196
  | `GET /api/test-history?dag_id=&task_id=&node_id=&limit=` | one test's outcome per run |
190
197
  | `GET /api/unique-tests?dag_id=&task_id=&run_id=&full=` | distinct test count (+ list when `full`) |
191
198
  | `DELETE /api/reports/{report_id}` | delete a report (RBAC-gated) |
@@ -251,6 +258,13 @@ Download them from a report's detail view, or `GET
251
258
  | built-in default | `/opt/airflow/pytest-reports` | fallback |
252
259
  | `AIRFLOW_PYTEST_PLUGIN_ENABLE` (env) | `True` | reader on/off — see below |
253
260
  | `AIRFLOW_PYTEST_SCAN_CACHE_TTL` (env) | `2.0` | seconds a directory scan is reused (`0` disables) |
261
+ | `AIRFLOW_PYTEST_RETENTION_MAX_AGE_DAYS` (env/cfg) | — | delete runs older than N days |
262
+ | `AIRFLOW_PYTEST_RETENTION_MAX_RUNS` (env/cfg) | — | keep at most N newest runs per dag·task |
263
+ | `AIRFLOW_PYTEST_RETENTION_MAX_TOTAL_MB` (env/cfg) | — | total report-tree budget in MB |
264
+ | `AIRFLOW_PYTEST_FLAKY_WINDOW` (env/cfg) | `30` | default recent runs the flaky detector scans |
265
+ | `AIRFLOW_PYTEST_FLAKY_QUARANTINE_SCORE` (env/cfg) | `0.5` | flakiness score (0–1) that flags a test for quarantine |
266
+ | `AIRFLOW_PYTEST_FLAKY_MIN_SCORE` (env/cfg) | `0.1` | flakiness score (0–1) below which a test is not counted as flaky |
267
+ | `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
268
 
255
269
  **Enable / disable the reader.** Set `AIRFLOW_PYTEST_PLUGIN_ENABLE` to a falsey
256
270
  value (`0`, `false`, `no`, `off`) to stop the plugin registering its UI and API
@@ -270,6 +284,34 @@ single scan for `AIRFLOW_PYTEST_SCAN_CACHE_TTL` seconds (default `2.0`; deletes
270
284
  invalidate it immediately). New runs therefore appear within a couple of seconds (or
271
285
  on **Refresh**); set it to `0` for no caching, or higher on a very large tree.
272
286
 
287
+ ## Retention (auto-cleanup)
288
+
289
+ Reports accumulate forever unless you prune them. Set any of the
290
+ `AIRFLOW_PYTEST_RETENTION_*` knobs above (all opt-in; unset = keep everything) and
291
+ schedule `prune_reports` from a maintenance DAG:
292
+
293
+ ```python
294
+ from airflow import DAG
295
+ from airflow.providers.standard.operators.python import PythonOperator
296
+ from airflow_pytest_plugin import prune_reports
297
+
298
+ with DAG("pytest_reports_retention", schedule="@daily", catchup=False, ...):
299
+ PythonOperator(task_id="prune", python_callable=prune_reports)
300
+ ```
301
+
302
+ Each knob is a dimension and they combine as a union — a run is deleted if **any**
303
+ applies:
304
+
305
+ - **age** — older than `…_MAX_AGE_DAYS`;
306
+ - **count** — beyond the newest `…_MAX_RUNS` of its dag·task;
307
+ - **size** — oldest-first until the tree fits `…_MAX_TOTAL_MB`.
308
+
309
+ The **newest run of each dag·task is always kept**, so a task's latest result never
310
+ disappears. `prune_reports(dry_run=True)` reports what *would* go without deleting
311
+ (its `RetentionResult` carries `deleted`, `freed_bytes`, `scanned`). Cleanup is
312
+ scheduler-driven — the plugin never deletes on its own. For a custom policy, build a
313
+ `RetentionPolicy(...)` and pass it (`prune_reports(policy)`).
314
+
273
315
  ## Architecture (SOLID)
274
316
 
275
317
  Mirrors the operator's layering — each piece has one reason to change:
@@ -280,6 +322,7 @@ Mirrors the operator's layering — each piece has one reason to change:
280
322
  | `producer.ArchivingResultParser` | write JUnit XML + `meta.json` (extends the operator's parser) |
281
323
  | `sources.ReportSource` / `FileSystemReportSource` | read/index reports behind an interface (Dependency Inversion) |
282
324
  | `web.create_app` | map HTTP onto a `ReportSource` — knows nothing about the filesystem |
325
+ | `retention` | pure `select_expired` decision + a `prune` orchestrator over any `ReportSource` |
283
326
  | `plugin.PytestReportsPlugin` | register the app with Airflow |
284
327
  | `compat` | the only module that imports Airflow; version differences resolved once |
285
328
  | `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.2"
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."""