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.
- {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/PKG-INFO +47 -8
- {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/README.md +46 -7
- {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/pyproject.toml +1 -1
- {airflow_pytest_plugin-0.3.0 → 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.3.0 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/models.py +13 -0
- airflow_pytest_plugin-0.3.1/src/airflow_pytest_plugin/retention.py +253 -0
- {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/sources/base.py +8 -0
- {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/sources/filesystem.py +41 -11
- airflow_pytest_plugin-0.3.1/src/airflow_pytest_plugin/web/routes/flaky.py +162 -0
- {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/web/routes/reports.py +9 -2
- {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/web/templates.py +274 -41
- airflow_pytest_plugin-0.3.1/tests/test_config.py +145 -0
- {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/tests/test_filesystem_source.py +43 -0
- {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/tests/test_models.py +23 -0
- airflow_pytest_plugin-0.3.1/tests/test_retention.py +287 -0
- {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/tests/test_web_app.py +112 -1
- airflow_pytest_plugin-0.3.0/src/airflow_pytest_plugin/config.py +0 -79
- airflow_pytest_plugin-0.3.0/src/airflow_pytest_plugin/web/routes/flaky.py +0 -106
- airflow_pytest_plugin-0.3.0/tests/test_config.py +0 -82
- {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/.gitignore +0 -0
- {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/LICENSE +0 -0
- {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/examples/example_dag.py +0 -0
- {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/compat/__init__.py +0 -0
- {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/compat/airflow.py +0 -0
- {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/layout.py +0 -0
- {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/plugin.py +0 -0
- {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/producer/__init__.py +0 -0
- {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/producer/archiving_parser.py +0 -0
- {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/sources/__init__.py +0 -0
- {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/version.py +0 -0
- {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/web/__init__.py +0 -0
- {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/web/__main__.py +0 -0
- {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/web/app.py +0 -0
- {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/web/routes/__init__.py +0 -0
- {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/web/routes/common.py +0 -0
- {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/web/routes/compare.py +0 -0
- {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/web/routes/failures.py +0 -0
- {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/src/airflow_pytest_plugin/web/routes/monitoring.py +0 -0
- {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/tests/conftest.py +0 -0
- {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/tests/test_archiving_parser.py +0 -0
- {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/tests/test_authz_integration.py +0 -0
- {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/tests/test_compat.py +0 -0
- {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/tests/test_layout.py +0 -0
- {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/tests/test_package.py +0 -0
- {airflow_pytest_plugin-0.3.0 → airflow_pytest_plugin-0.3.1}/tests/test_plugin.py +0 -0
- {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.
|
|
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,
|
|
102
|
-
|
|
103
|
-
|
|
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
|

|
|
106
108
|
|
|
@@ -112,9 +114,10 @@ output on expand:
|
|
|
112
114
|

|
|
113
115
|
|
|
114
116
|
**Flaky tests & comparison** — from a run, *Flaky tests* lists the tests that
|
|
115
|
-
both pass and fail across recent runs
|
|
116
|
-
|
|
117
|
-
|
|
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
|

|
|
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
|
|
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,
|
|
58
|
-
|
|
59
|
-
|
|
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
|

|
|
62
64
|
|
|
@@ -68,9 +70,10 @@ output on expand:
|
|
|
68
70
|

|
|
69
71
|
|
|
70
72
|
**Flaky tests & comparison** — from a run, *Flaky tests* lists the tests that
|
|
71
|
-
both pass and fail across recent runs
|
|
72
|
-
|
|
73
|
-
|
|
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
|

|
|
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
|
|
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 |
|
{airflow_pytest_plugin-0.3.0 → 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.3.0 → 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."""
|
|
@@ -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)
|