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.
- {airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/PKG-INFO +89 -11
- {airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/README.md +88 -10
- {airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/pyproject.toml +1 -1
- {airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/__init__.py +4 -0
- airflow_pytest_plugin-0.3.1/src/airflow_pytest_plugin/config.py +177 -0
- {airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/models.py +13 -0
- {airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/plugin.py +11 -3
- {airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/producer/archiving_parser.py +15 -0
- airflow_pytest_plugin-0.3.1/src/airflow_pytest_plugin/retention.py +253 -0
- {airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/sources/base.py +17 -0
- {airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/sources/filesystem.py +139 -29
- airflow_pytest_plugin-0.3.1/src/airflow_pytest_plugin/web/app.py +155 -0
- airflow_pytest_plugin-0.3.1/src/airflow_pytest_plugin/web/routes/__init__.py +20 -0
- airflow_pytest_plugin-0.3.1/src/airflow_pytest_plugin/web/routes/common.py +53 -0
- airflow_pytest_plugin-0.3.1/src/airflow_pytest_plugin/web/routes/compare.py +94 -0
- airflow_pytest_plugin-0.3.1/src/airflow_pytest_plugin/web/routes/failures.py +89 -0
- airflow_pytest_plugin-0.3.1/src/airflow_pytest_plugin/web/routes/flaky.py +162 -0
- airflow_pytest_plugin-0.3.1/src/airflow_pytest_plugin/web/routes/monitoring.py +75 -0
- airflow_pytest_plugin-0.3.1/src/airflow_pytest_plugin/web/routes/reports.py +219 -0
- {airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/web/templates.py +1003 -91
- {airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/tests/conftest.py +29 -0
- {airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/tests/test_archiving_parser.py +14 -0
- airflow_pytest_plugin-0.3.1/tests/test_config.py +145 -0
- {airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/tests/test_filesystem_source.py +138 -1
- {airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/tests/test_models.py +23 -0
- {airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/tests/test_plugin.py +14 -0
- airflow_pytest_plugin-0.3.1/tests/test_retention.py +287 -0
- airflow_pytest_plugin-0.3.1/tests/test_web_app.py +759 -0
- airflow_pytest_plugin-0.2.1/src/airflow_pytest_plugin/config.py +0 -39
- airflow_pytest_plugin-0.2.1/src/airflow_pytest_plugin/web/app.py +0 -235
- airflow_pytest_plugin-0.2.1/tests/test_config.py +0 -42
- airflow_pytest_plugin-0.2.1/tests/test_web_app.py +0 -350
- {airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/.gitignore +0 -0
- {airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/LICENSE +0 -0
- {airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/examples/example_dag.py +0 -0
- {airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/compat/__init__.py +0 -0
- {airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/compat/airflow.py +0 -0
- {airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/layout.py +0 -0
- {airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/producer/__init__.py +0 -0
- {airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/sources/__init__.py +0 -0
- {airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/version.py +0 -0
- {airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/web/__init__.py +0 -0
- {airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/web/__main__.py +0 -0
- {airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/tests/test_authz_integration.py +0 -0
- {airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/tests/test_compat.py +0 -0
- {airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/tests/test_layout.py +0 -0
- {airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/tests/test_package.py +0 -0
- {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.
|
|
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,
|
|
100
|
-
|
|
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
|

|
|
103
108
|
|
|
104
|
-
**A single run** — a clickable success donut (pass-rate
|
|
105
|
-
slice to filter by status),
|
|
106
|
-
|
|
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
|

|
|
109
115
|
|
|
110
|
-
**
|
|
111
|
-
across
|
|
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
|
+

|
|
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
|
+

|
|
112
129
|
|
|
113
130
|

|
|
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` | `
|
|
220
|
-
| `GET /api/
|
|
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,
|
|
56
|
-
|
|
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
|

|
|
59
64
|
|
|
60
|
-
**A single run** — a clickable success donut (pass-rate
|
|
61
|
-
slice to filter by status),
|
|
62
|
-
|
|
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
|

|
|
65
71
|
|
|
66
|
-
**
|
|
67
|
-
across
|
|
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
|
+

|
|
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
|
+

|
|
68
85
|
|
|
69
86
|

|
|
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` | `
|
|
176
|
-
| `GET /api/
|
|
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 |
|
{airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/__init__.py
RENAMED
|
@@ -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)
|
{airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/models.py
RENAMED
|
@@ -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."""
|
{airflow_pytest_plugin-0.2.1 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/plugin.py
RENAMED
|
@@ -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
|