airflow-pytest-plugin 0.1.0__tar.gz → 0.2.0__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.1.0 → airflow_pytest_plugin-0.2.0}/PKG-INFO +85 -7
- {airflow_pytest_plugin-0.1.0 → airflow_pytest_plugin-0.2.0}/README.md +84 -6
- {airflow_pytest_plugin-0.1.0 → airflow_pytest_plugin-0.2.0}/examples/example_dag.py +3 -3
- {airflow_pytest_plugin-0.1.0 → airflow_pytest_plugin-0.2.0}/pyproject.toml +1 -1
- {airflow_pytest_plugin-0.1.0 → airflow_pytest_plugin-0.2.0}/src/airflow_pytest_plugin/__init__.py +4 -18
- {airflow_pytest_plugin-0.1.0 → airflow_pytest_plugin-0.2.0}/src/airflow_pytest_plugin/compat/__init__.py +8 -0
- airflow_pytest_plugin-0.2.0/src/airflow_pytest_plugin/compat/airflow.py +128 -0
- {airflow_pytest_plugin-0.1.0 → airflow_pytest_plugin-0.2.0}/src/airflow_pytest_plugin/config.py +1 -8
- {airflow_pytest_plugin-0.1.0 → airflow_pytest_plugin-0.2.0}/src/airflow_pytest_plugin/layout.py +10 -20
- {airflow_pytest_plugin-0.1.0 → airflow_pytest_plugin-0.2.0}/src/airflow_pytest_plugin/models.py +13 -18
- {airflow_pytest_plugin-0.1.0 → airflow_pytest_plugin-0.2.0}/src/airflow_pytest_plugin/plugin.py +5 -22
- {airflow_pytest_plugin-0.1.0 → airflow_pytest_plugin-0.2.0}/src/airflow_pytest_plugin/producer/__init__.py +3 -3
- {airflow_pytest_plugin-0.1.0 → airflow_pytest_plugin-0.2.0}/src/airflow_pytest_plugin/producer/archiving_parser.py +76 -47
- {airflow_pytest_plugin-0.1.0 → airflow_pytest_plugin-0.2.0}/src/airflow_pytest_plugin/sources/base.py +15 -12
- {airflow_pytest_plugin-0.1.0 → airflow_pytest_plugin-0.2.0}/src/airflow_pytest_plugin/sources/filesystem.py +100 -51
- {airflow_pytest_plugin-0.1.0 → airflow_pytest_plugin-0.2.0}/src/airflow_pytest_plugin/version.py +1 -1
- airflow_pytest_plugin-0.2.0/src/airflow_pytest_plugin/web/app.py +184 -0
- airflow_pytest_plugin-0.2.0/src/airflow_pytest_plugin/web/templates.py +1468 -0
- {airflow_pytest_plugin-0.1.0 → airflow_pytest_plugin-0.2.0}/tests/conftest.py +52 -2
- airflow_pytest_plugin-0.2.0/tests/test_archiving_parser.py +237 -0
- airflow_pytest_plugin-0.2.0/tests/test_authz_integration.py +113 -0
- airflow_pytest_plugin-0.2.0/tests/test_compat.py +129 -0
- airflow_pytest_plugin-0.2.0/tests/test_config.py +42 -0
- airflow_pytest_plugin-0.2.0/tests/test_filesystem_source.py +336 -0
- {airflow_pytest_plugin-0.1.0 → airflow_pytest_plugin-0.2.0}/tests/test_layout.py +6 -0
- {airflow_pytest_plugin-0.1.0 → airflow_pytest_plugin-0.2.0}/tests/test_models.py +10 -0
- airflow_pytest_plugin-0.2.0/tests/test_package.py +30 -0
- {airflow_pytest_plugin-0.1.0 → airflow_pytest_plugin-0.2.0}/tests/test_plugin.py +2 -5
- airflow_pytest_plugin-0.2.0/tests/test_version.py +31 -0
- airflow_pytest_plugin-0.2.0/tests/test_web_app.py +264 -0
- airflow_pytest_plugin-0.1.0/src/airflow_pytest_plugin/compat/airflow.py +0 -70
- airflow_pytest_plugin-0.1.0/src/airflow_pytest_plugin/web/app.py +0 -103
- airflow_pytest_plugin-0.1.0/src/airflow_pytest_plugin/web/templates.py +0 -564
- airflow_pytest_plugin-0.1.0/tests/test_archiving_parser.py +0 -81
- airflow_pytest_plugin-0.1.0/tests/test_filesystem_source.py +0 -104
- airflow_pytest_plugin-0.1.0/tests/test_web_app.py +0 -79
- {airflow_pytest_plugin-0.1.0 → airflow_pytest_plugin-0.2.0}/.gitignore +0 -0
- {airflow_pytest_plugin-0.1.0 → airflow_pytest_plugin-0.2.0}/LICENSE +0 -0
- {airflow_pytest_plugin-0.1.0 → airflow_pytest_plugin-0.2.0}/src/airflow_pytest_plugin/sources/__init__.py +0 -0
- {airflow_pytest_plugin-0.1.0 → airflow_pytest_plugin-0.2.0}/src/airflow_pytest_plugin/web/__init__.py +0 -0
- {airflow_pytest_plugin-0.1.0 → airflow_pytest_plugin-0.2.0}/src/airflow_pytest_plugin/web/__main__.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.2.0
|
|
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
|
|
@@ -76,9 +76,37 @@ It has two halves that share one on-disk layout:
|
|
|
76
76
|
|
|
77
77
|
| Side | Where it runs | What it is |
|
|
78
78
|
| --- | --- | --- |
|
|
79
|
-
| **Producer** | the worker | `
|
|
79
|
+
| **Producer** | the worker | `ArchivingResultParser`, a drop-in `parser=` for `PytestOperator` |
|
|
80
80
|
| **Reader** | the API server | a FastAPI app + single-page viewer, registered as an Airflow plugin |
|
|
81
81
|
|
|
82
|
+
## Contents
|
|
83
|
+
|
|
84
|
+
- [Screenshots](#screenshots)
|
|
85
|
+
- [Install](#install)
|
|
86
|
+
- [Quickstart](#quickstart)
|
|
87
|
+
- [Do I need `cleanup="never"`?](#do-i-need-cleanupnever)
|
|
88
|
+
- [How it works](#how-it-works)
|
|
89
|
+
- [HTTP API](#http-api)
|
|
90
|
+
- [Access control (RBAC)](#access-control-rbac)
|
|
91
|
+
- [Configuration](#configuration)
|
|
92
|
+
- [Architecture (SOLID)](#architecture-solid)
|
|
93
|
+
- [Development](#development)
|
|
94
|
+
- [License](#license)
|
|
95
|
+
|
|
96
|
+
## Screenshots
|
|
97
|
+
|
|
98
|
+
**Overview** — the run list with the historical chart (per-status legend
|
|
99
|
+
toggles, run numbers, and a carousel beyond 30 runs), KPI cards, and Airflow-
|
|
100
|
+
matched colours and font:
|
|
101
|
+
|
|
102
|
+

|
|
103
|
+
|
|
104
|
+
**A single run** — a clickable success donut (pass-rate in the centre; click a
|
|
105
|
+
slice to filter by status), the counts, and every test's captured output on
|
|
106
|
+
expand:
|
|
107
|
+
|
|
108
|
+

|
|
109
|
+
|
|
82
110
|
---
|
|
83
111
|
|
|
84
112
|
## Install
|
|
@@ -97,12 +125,12 @@ enough there too; the `[web]` extra only adds the standalone dev server.
|
|
|
97
125
|
|
|
98
126
|
```python
|
|
99
127
|
from airflow_pytest_operator import PytestOperator
|
|
100
|
-
from airflow_pytest_plugin import
|
|
128
|
+
from airflow_pytest_plugin import ArchivingResultParser
|
|
101
129
|
|
|
102
130
|
PytestOperator(
|
|
103
131
|
task_id="run_tests",
|
|
104
132
|
test_path="tests/",
|
|
105
|
-
parser=
|
|
133
|
+
parser=ArchivingResultParser(), # was JUnitResultParser()
|
|
106
134
|
)
|
|
107
135
|
```
|
|
108
136
|
|
|
@@ -140,7 +168,7 @@ python -m airflow_pytest_plugin.web --root ./pytest-reports --port 8000
|
|
|
140
168
|
|
|
141
169
|
**No.** In the operator, the *parser* owns the report location, and a
|
|
142
170
|
**parser-supplied directory is never deleted by the runner under any cleanup
|
|
143
|
-
policy**. `
|
|
171
|
+
policy**. `ArchivingResultParser` supplies its own directory, so reports
|
|
144
172
|
always survive regardless of the runner's `cleanup` setting. `cleanup="never"`
|
|
145
173
|
only matters when you let the runner use throwaway temp dirs — which is exactly
|
|
146
174
|
the fragile path (random names, no `dag/run/task` association, not visible to
|
|
@@ -152,7 +180,7 @@ other workers) this plugin replaces.
|
|
|
152
180
|
worker shared volume API server
|
|
153
181
|
────── ───────────── ──────────
|
|
154
182
|
PytestOperator {root}/{dag}/{run}/ FastAPI app
|
|
155
|
-
└─
|
|
183
|
+
└─ ArchivingResultParser ──▶ {task}/t{try}/ ◀──── FileSystemReportSource
|
|
156
184
|
report_request() → path ├─ junit.xml └─ lists meta.json,
|
|
157
185
|
parse() → meta.json └─ meta.json parses junit.xml
|
|
158
186
|
```
|
|
@@ -180,9 +208,59 @@ runtime. Endpoints (relative to the mount):
|
|
|
180
208
|
| `GET /` | the single-page viewer (HTML) |
|
|
181
209
|
| `GET /api/reports?dag_id=&run_id=` | summaries, newest first |
|
|
182
210
|
| `GET /api/reports/{report_id}` | one report with per-case rows |
|
|
211
|
+
| `DELETE /api/reports/{report_id}` | delete a report (RBAC-gated) |
|
|
212
|
+
| `GET /api/reports/{report_id}/allure.zip` | raw Allure results as a zip (if any) |
|
|
183
213
|
| `GET /api/health` | `{"status": "ok"}` |
|
|
184
214
|
| `GET /api/docs` | OpenAPI docs |
|
|
185
215
|
|
|
216
|
+
The reads (`GET`) and the delete are gated by Airflow RBAC — see below.
|
|
217
|
+
|
|
218
|
+
## Access control (RBAC)
|
|
219
|
+
|
|
220
|
+
Access is enforced the way Airflow 3 enforces it: every check goes through
|
|
221
|
+
Airflow's **auth manager** (`is_authorized_dag(...)`) — the same call Airflow's
|
|
222
|
+
own DAG-run endpoints make — keyed by the report's `dag_id` and the
|
|
223
|
+
authenticated user. Two permissions are checked:
|
|
224
|
+
|
|
225
|
+
| Action | Airflow 3.x check | Airflow 2.x (FAB) equivalent |
|
|
226
|
+
| --- | --- | --- |
|
|
227
|
+
| **See / open a report** | `is_authorized_dag(method="GET", access_entity=RUN)` | `can_read` on the DAG |
|
|
228
|
+
| **Delete a report** | `is_authorized_dag(method="POST", access_entity=RUN)` (may trigger the DAG) | trigger / `can_create` on the DAG |
|
|
229
|
+
|
|
230
|
+
The report list is filtered to the DAGs you may read, opening a report you can't
|
|
231
|
+
read returns `403`, and deleting one requires permission to **trigger** its DAG.
|
|
232
|
+
Every check **fails closed**: if the auth manager can't be consulted, access is
|
|
233
|
+
denied.
|
|
234
|
+
|
|
235
|
+
**Airflow 2 → 3 mapping.** Airflow 2's FAB used `(action, resource)` pairs —
|
|
236
|
+
`can_read` / `can_edit` / `can_delete` / `can_create` on a resource such as
|
|
237
|
+
`DAG:<id>`. Airflow 3 replaced these with the auth manager's `method`: `GET` ↔
|
|
238
|
+
read, `POST` ↔ create, `PUT` ↔ edit, `DELETE` ↔ delete, `MENU` ↔ menu access.
|
|
239
|
+
This plugin maps **read → `GET`** and **delete → `POST`** (trigger), so it
|
|
240
|
+
inherits your existing per-DAG roles with no extra configuration.
|
|
241
|
+
|
|
242
|
+
**Plugin visibility.** The nav entry is an Airflow `external_views` item, which
|
|
243
|
+
has no per-permission gate, so the menu link is visible to every signed-in user;
|
|
244
|
+
access is enforced on the **content** (a user who may read no DAG sees an empty
|
|
245
|
+
list and `403` on direct links). The standalone dev server (no Airflow auth)
|
|
246
|
+
allows everything.
|
|
247
|
+
|
|
248
|
+
## Allure / TestOps export
|
|
249
|
+
|
|
250
|
+
Opt in per task and install [`allure-pytest`](https://pypi.org/project/allure-pytest/)
|
|
251
|
+
on the worker:
|
|
252
|
+
|
|
253
|
+
```python
|
|
254
|
+
parser=ArchivingResultParser(allure=True)
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
The parser then adds `--alluredir` (pytest errors with *unrecognized arguments*
|
|
258
|
+
if `allure-pytest` is missing), so the **raw Allure results** are archived next to
|
|
259
|
+
the report, with an `executor.json` linking the launch back to the Airflow run.
|
|
260
|
+
Download them from a report's detail view, or `GET
|
|
261
|
+
/api/reports/{id}/allure.zip` — then upload to [Allure TestOps](https://qameta.io/)
|
|
262
|
+
(`allurectl upload …`). The JUnit viewer is unaffected; both artifacts coexist.
|
|
263
|
+
|
|
186
264
|
## Configuration
|
|
187
265
|
|
|
188
266
|
| Setting | Default | Purpose |
|
|
@@ -198,7 +276,7 @@ Mirrors the operator's layering — each piece has one reason to change:
|
|
|
198
276
|
| Module | Responsibility |
|
|
199
277
|
| --- | --- |
|
|
200
278
|
| `layout.ReportLayout` | the single `ReportRef → directory` mapping, shared by both sides |
|
|
201
|
-
| `producer.
|
|
279
|
+
| `producer.ArchivingResultParser` | write JUnit XML + `meta.json` (extends the operator's parser) |
|
|
202
280
|
| `sources.ReportSource` / `FileSystemReportSource` | read/index reports behind an interface (Dependency Inversion) |
|
|
203
281
|
| `web.create_app` | map HTTP onto a `ReportSource` — knows nothing about the filesystem |
|
|
204
282
|
| `plugin.PytestReportsPlugin` | register the app with Airflow |
|
|
@@ -32,9 +32,37 @@ It has two halves that share one on-disk layout:
|
|
|
32
32
|
|
|
33
33
|
| Side | Where it runs | What it is |
|
|
34
34
|
| --- | --- | --- |
|
|
35
|
-
| **Producer** | the worker | `
|
|
35
|
+
| **Producer** | the worker | `ArchivingResultParser`, a drop-in `parser=` for `PytestOperator` |
|
|
36
36
|
| **Reader** | the API server | a FastAPI app + single-page viewer, registered as an Airflow plugin |
|
|
37
37
|
|
|
38
|
+
## Contents
|
|
39
|
+
|
|
40
|
+
- [Screenshots](#screenshots)
|
|
41
|
+
- [Install](#install)
|
|
42
|
+
- [Quickstart](#quickstart)
|
|
43
|
+
- [Do I need `cleanup="never"`?](#do-i-need-cleanupnever)
|
|
44
|
+
- [How it works](#how-it-works)
|
|
45
|
+
- [HTTP API](#http-api)
|
|
46
|
+
- [Access control (RBAC)](#access-control-rbac)
|
|
47
|
+
- [Configuration](#configuration)
|
|
48
|
+
- [Architecture (SOLID)](#architecture-solid)
|
|
49
|
+
- [Development](#development)
|
|
50
|
+
- [License](#license)
|
|
51
|
+
|
|
52
|
+
## Screenshots
|
|
53
|
+
|
|
54
|
+
**Overview** — the run list with the historical chart (per-status legend
|
|
55
|
+
toggles, run numbers, and a carousel beyond 30 runs), KPI cards, and Airflow-
|
|
56
|
+
matched colours and font:
|
|
57
|
+
|
|
58
|
+

|
|
59
|
+
|
|
60
|
+
**A single run** — a clickable success donut (pass-rate in the centre; click a
|
|
61
|
+
slice to filter by status), the counts, and every test's captured output on
|
|
62
|
+
expand:
|
|
63
|
+
|
|
64
|
+

|
|
65
|
+
|
|
38
66
|
---
|
|
39
67
|
|
|
40
68
|
## Install
|
|
@@ -53,12 +81,12 @@ enough there too; the `[web]` extra only adds the standalone dev server.
|
|
|
53
81
|
|
|
54
82
|
```python
|
|
55
83
|
from airflow_pytest_operator import PytestOperator
|
|
56
|
-
from airflow_pytest_plugin import
|
|
84
|
+
from airflow_pytest_plugin import ArchivingResultParser
|
|
57
85
|
|
|
58
86
|
PytestOperator(
|
|
59
87
|
task_id="run_tests",
|
|
60
88
|
test_path="tests/",
|
|
61
|
-
parser=
|
|
89
|
+
parser=ArchivingResultParser(), # was JUnitResultParser()
|
|
62
90
|
)
|
|
63
91
|
```
|
|
64
92
|
|
|
@@ -96,7 +124,7 @@ python -m airflow_pytest_plugin.web --root ./pytest-reports --port 8000
|
|
|
96
124
|
|
|
97
125
|
**No.** In the operator, the *parser* owns the report location, and a
|
|
98
126
|
**parser-supplied directory is never deleted by the runner under any cleanup
|
|
99
|
-
policy**. `
|
|
127
|
+
policy**. `ArchivingResultParser` supplies its own directory, so reports
|
|
100
128
|
always survive regardless of the runner's `cleanup` setting. `cleanup="never"`
|
|
101
129
|
only matters when you let the runner use throwaway temp dirs — which is exactly
|
|
102
130
|
the fragile path (random names, no `dag/run/task` association, not visible to
|
|
@@ -108,7 +136,7 @@ other workers) this plugin replaces.
|
|
|
108
136
|
worker shared volume API server
|
|
109
137
|
────── ───────────── ──────────
|
|
110
138
|
PytestOperator {root}/{dag}/{run}/ FastAPI app
|
|
111
|
-
└─
|
|
139
|
+
└─ ArchivingResultParser ──▶ {task}/t{try}/ ◀──── FileSystemReportSource
|
|
112
140
|
report_request() → path ├─ junit.xml └─ lists meta.json,
|
|
113
141
|
parse() → meta.json └─ meta.json parses junit.xml
|
|
114
142
|
```
|
|
@@ -136,9 +164,59 @@ runtime. Endpoints (relative to the mount):
|
|
|
136
164
|
| `GET /` | the single-page viewer (HTML) |
|
|
137
165
|
| `GET /api/reports?dag_id=&run_id=` | summaries, newest first |
|
|
138
166
|
| `GET /api/reports/{report_id}` | one report with per-case rows |
|
|
167
|
+
| `DELETE /api/reports/{report_id}` | delete a report (RBAC-gated) |
|
|
168
|
+
| `GET /api/reports/{report_id}/allure.zip` | raw Allure results as a zip (if any) |
|
|
139
169
|
| `GET /api/health` | `{"status": "ok"}` |
|
|
140
170
|
| `GET /api/docs` | OpenAPI docs |
|
|
141
171
|
|
|
172
|
+
The reads (`GET`) and the delete are gated by Airflow RBAC — see below.
|
|
173
|
+
|
|
174
|
+
## Access control (RBAC)
|
|
175
|
+
|
|
176
|
+
Access is enforced the way Airflow 3 enforces it: every check goes through
|
|
177
|
+
Airflow's **auth manager** (`is_authorized_dag(...)`) — the same call Airflow's
|
|
178
|
+
own DAG-run endpoints make — keyed by the report's `dag_id` and the
|
|
179
|
+
authenticated user. Two permissions are checked:
|
|
180
|
+
|
|
181
|
+
| Action | Airflow 3.x check | Airflow 2.x (FAB) equivalent |
|
|
182
|
+
| --- | --- | --- |
|
|
183
|
+
| **See / open a report** | `is_authorized_dag(method="GET", access_entity=RUN)` | `can_read` on the DAG |
|
|
184
|
+
| **Delete a report** | `is_authorized_dag(method="POST", access_entity=RUN)` (may trigger the DAG) | trigger / `can_create` on the DAG |
|
|
185
|
+
|
|
186
|
+
The report list is filtered to the DAGs you may read, opening a report you can't
|
|
187
|
+
read returns `403`, and deleting one requires permission to **trigger** its DAG.
|
|
188
|
+
Every check **fails closed**: if the auth manager can't be consulted, access is
|
|
189
|
+
denied.
|
|
190
|
+
|
|
191
|
+
**Airflow 2 → 3 mapping.** Airflow 2's FAB used `(action, resource)` pairs —
|
|
192
|
+
`can_read` / `can_edit` / `can_delete` / `can_create` on a resource such as
|
|
193
|
+
`DAG:<id>`. Airflow 3 replaced these with the auth manager's `method`: `GET` ↔
|
|
194
|
+
read, `POST` ↔ create, `PUT` ↔ edit, `DELETE` ↔ delete, `MENU` ↔ menu access.
|
|
195
|
+
This plugin maps **read → `GET`** and **delete → `POST`** (trigger), so it
|
|
196
|
+
inherits your existing per-DAG roles with no extra configuration.
|
|
197
|
+
|
|
198
|
+
**Plugin visibility.** The nav entry is an Airflow `external_views` item, which
|
|
199
|
+
has no per-permission gate, so the menu link is visible to every signed-in user;
|
|
200
|
+
access is enforced on the **content** (a user who may read no DAG sees an empty
|
|
201
|
+
list and `403` on direct links). The standalone dev server (no Airflow auth)
|
|
202
|
+
allows everything.
|
|
203
|
+
|
|
204
|
+
## Allure / TestOps export
|
|
205
|
+
|
|
206
|
+
Opt in per task and install [`allure-pytest`](https://pypi.org/project/allure-pytest/)
|
|
207
|
+
on the worker:
|
|
208
|
+
|
|
209
|
+
```python
|
|
210
|
+
parser=ArchivingResultParser(allure=True)
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
The parser then adds `--alluredir` (pytest errors with *unrecognized arguments*
|
|
214
|
+
if `allure-pytest` is missing), so the **raw Allure results** are archived next to
|
|
215
|
+
the report, with an `executor.json` linking the launch back to the Airflow run.
|
|
216
|
+
Download them from a report's detail view, or `GET
|
|
217
|
+
/api/reports/{id}/allure.zip` — then upload to [Allure TestOps](https://qameta.io/)
|
|
218
|
+
(`allurectl upload …`). The JUnit viewer is unaffected; both artifacts coexist.
|
|
219
|
+
|
|
142
220
|
## Configuration
|
|
143
221
|
|
|
144
222
|
| Setting | Default | Purpose |
|
|
@@ -154,7 +232,7 @@ Mirrors the operator's layering — each piece has one reason to change:
|
|
|
154
232
|
| Module | Responsibility |
|
|
155
233
|
| --- | --- |
|
|
156
234
|
| `layout.ReportLayout` | the single `ReportRef → directory` mapping, shared by both sides |
|
|
157
|
-
| `producer.
|
|
235
|
+
| `producer.ArchivingResultParser` | write JUnit XML + `meta.json` (extends the operator's parser) |
|
|
158
236
|
| `sources.ReportSource` / `FileSystemReportSource` | read/index reports behind an interface (Dependency Inversion) |
|
|
159
237
|
| `web.create_app` | map HTTP onto a `ReportSource` — knows nothing about the filesystem |
|
|
160
238
|
| `plugin.PytestReportsPlugin` | register the app with Airflow |
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"""Minimal DAG wiring the archiving parser into a PytestOperator.
|
|
16
16
|
|
|
17
17
|
The only change from a plain operator setup is the ``parser=`` argument: swap
|
|
18
|
-
``JUnitResultParser`` for ``
|
|
18
|
+
``JUnitResultParser`` for ``ArchivingResultParser`` and every run is
|
|
19
19
|
archived where the Pytest Reports UI can find it. No ``cleanup`` tuning needed
|
|
20
20
|
-- a parser-supplied directory is never removed by the runner.
|
|
21
21
|
"""
|
|
@@ -26,7 +26,7 @@ import pendulum
|
|
|
26
26
|
from airflow import DAG
|
|
27
27
|
from airflow_pytest_operator import PytestOperator
|
|
28
28
|
|
|
29
|
-
from airflow_pytest_plugin import
|
|
29
|
+
from airflow_pytest_plugin import ArchivingResultParser
|
|
30
30
|
|
|
31
31
|
with DAG(
|
|
32
32
|
dag_id="pytest_reports_example",
|
|
@@ -40,7 +40,7 @@ with DAG(
|
|
|
40
40
|
test_path="tests/",
|
|
41
41
|
# report_root defaults to AIRFLOW_PYTEST_REPORTS_ROOT / the
|
|
42
42
|
# [pytest_reports] reports_root config / /opt/airflow/pytest-reports.
|
|
43
|
-
parser=
|
|
43
|
+
parser=ArchivingResultParser(allure=False),
|
|
44
44
|
# Tests failing should not abort the pipeline here; the outcome is in
|
|
45
45
|
# XCom and in the reports UI either way.
|
|
46
46
|
fail_on_test_failure=False,
|
{airflow_pytest_plugin-0.1.0 → airflow_pytest_plugin-0.2.0}/src/airflow_pytest_plugin/__init__.py
RENAMED
|
@@ -12,19 +12,7 @@
|
|
|
12
12
|
# See the License for the specific language governing permissions and
|
|
13
13
|
# limitations under the License.
|
|
14
14
|
|
|
15
|
-
"""View airflow-pytest-operator results in the Airflow 3 web UI.
|
|
16
|
-
|
|
17
|
-
A producer-side parser archives each JUnit report plus a ``meta.json`` sidecar;
|
|
18
|
-
a reader-side source and a FastAPI plugin serve a viewer over them.
|
|
19
|
-
|
|
20
|
-
Public API:
|
|
21
|
-
ArchivingJUnitResultParser -- producer-side parser (use as ``parser=``)
|
|
22
|
-
ReportSource / FileSystemReportSource -- reader interface + default source
|
|
23
|
-
ReportRef / ReportSummary / ReportDetail / CaseView -- view models
|
|
24
|
-
ReportLayout -- the shared on-disk layout
|
|
25
|
-
get_reports_root -- resolve the report root (env/conf/default)
|
|
26
|
-
create_app -- build the FastAPI app (lazy; needs FastAPI)
|
|
27
|
-
"""
|
|
15
|
+
"""View airflow-pytest-operator results in the Airflow 3 web UI."""
|
|
28
16
|
|
|
29
17
|
from __future__ import annotations
|
|
30
18
|
|
|
@@ -33,18 +21,16 @@ from typing import TYPE_CHECKING
|
|
|
33
21
|
from .config import get_reports_root
|
|
34
22
|
from .layout import ReportLayout
|
|
35
23
|
from .models import CaseView, ReportDetail, ReportRef, ReportSummary
|
|
36
|
-
from .producer import
|
|
24
|
+
from .producer import ArchivingResultParser
|
|
37
25
|
from .sources import FileSystemReportSource, ReportSource
|
|
38
26
|
from .version import __version__ as __version__
|
|
39
27
|
|
|
40
28
|
if TYPE_CHECKING:
|
|
41
|
-
# Exposed lazily via __getattr__ so importing the package never
|
|
42
|
-
# imports FastAPI (the producer-side parser must import cleanly on a worker
|
|
43
|
-
# that has no web stack).
|
|
29
|
+
# Exposed lazily via __getattr__ so importing the package never imports FastAPI.
|
|
44
30
|
from .web import create_app as create_app
|
|
45
31
|
|
|
46
32
|
__all__ = [
|
|
47
|
-
"
|
|
33
|
+
"ArchivingResultParser",
|
|
48
34
|
"ReportSource",
|
|
49
35
|
"FileSystemReportSource",
|
|
50
36
|
"ReportRef",
|
|
@@ -17,13 +17,21 @@
|
|
|
17
17
|
from __future__ import annotations
|
|
18
18
|
|
|
19
19
|
from .airflow import (
|
|
20
|
+
airflow_auth_available,
|
|
20
21
|
get_airflow_plugin_base,
|
|
21
22
|
get_conf_value,
|
|
22
23
|
get_current_context,
|
|
24
|
+
get_user_dependency,
|
|
25
|
+
is_authorized_to_read,
|
|
26
|
+
is_authorized_to_trigger,
|
|
23
27
|
)
|
|
24
28
|
|
|
25
29
|
__all__ = [
|
|
26
30
|
"get_current_context",
|
|
27
31
|
"get_airflow_plugin_base",
|
|
28
32
|
"get_conf_value",
|
|
33
|
+
"airflow_auth_available",
|
|
34
|
+
"get_user_dependency",
|
|
35
|
+
"is_authorized_to_read",
|
|
36
|
+
"is_authorized_to_trigger",
|
|
29
37
|
]
|
|
@@ -0,0 +1,128 @@
|
|
|
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
|
+
"""Airflow version compatibility shim -- the only module that imports Airflow.
|
|
16
|
+
|
|
17
|
+
Every Airflow import is lazy (inside a function) so importing the plugin never
|
|
18
|
+
drags in the Task SDK. 2.x/3.x differences resolved here, degrading gracefully
|
|
19
|
+
when Airflow is absent.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import logging
|
|
25
|
+
from typing import Any, cast
|
|
26
|
+
|
|
27
|
+
_log = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_current_context() -> dict[str, Any] | None:
|
|
31
|
+
"""Return the running task's Airflow context, or ``None`` if unavailable."""
|
|
32
|
+
# Airflow 3.x (Task SDK), then 2.x.
|
|
33
|
+
try:
|
|
34
|
+
from airflow.sdk import get_current_context as _gcc3
|
|
35
|
+
|
|
36
|
+
return dict(_gcc3())
|
|
37
|
+
except Exception:
|
|
38
|
+
pass
|
|
39
|
+
try:
|
|
40
|
+
from airflow.operators.python import get_current_context as _gcc2
|
|
41
|
+
|
|
42
|
+
return dict(_gcc2())
|
|
43
|
+
except Exception:
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def get_airflow_plugin_base() -> type[Any]:
|
|
48
|
+
"""Return ``airflow.plugins_manager.AirflowPlugin`` (raises if Airflow absent)."""
|
|
49
|
+
from airflow.plugins_manager import AirflowPlugin
|
|
50
|
+
|
|
51
|
+
return cast("type[Any]", AirflowPlugin)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def get_conf_value(section: str, key: str) -> str | None:
|
|
55
|
+
"""Read a value from ``airflow.cfg`` / env, or ``None`` if unset/absent."""
|
|
56
|
+
try:
|
|
57
|
+
from airflow.configuration import conf
|
|
58
|
+
|
|
59
|
+
# Annotate rather than cast: clean whether conf.get is typed str | None or Any.
|
|
60
|
+
value: str | None = conf.get(section, key, fallback=None)
|
|
61
|
+
return value
|
|
62
|
+
except Exception:
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def airflow_auth_available() -> bool:
|
|
67
|
+
"""True if Airflow 3's FastAPI auth (current-user dependency) is importable."""
|
|
68
|
+
try:
|
|
69
|
+
from airflow.api_fastapi.core_api.security import get_user # noqa: F401
|
|
70
|
+
|
|
71
|
+
return True
|
|
72
|
+
except Exception:
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def get_user_dependency() -> Any:
|
|
77
|
+
"""Return Airflow's ``get_user`` FastAPI dependency (call only when auth available)."""
|
|
78
|
+
from airflow.api_fastapi.core_api.security import get_user
|
|
79
|
+
|
|
80
|
+
return get_user
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def is_authorized_to_trigger(dag_id: str, user: Any) -> bool:
|
|
84
|
+
"""True if ``user`` may trigger ``dag_id`` (gates report deletion)."""
|
|
85
|
+
return _is_authorized_dag("POST", dag_id, user)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def is_authorized_to_read(dag_id: str, user: Any) -> bool:
|
|
89
|
+
"""True if ``user`` may read ``dag_id`` (gates listing / detail)."""
|
|
90
|
+
return _is_authorized_dag("GET", dag_id, user)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _is_authorized_dag(method: str, dag_id: str, user: Any) -> bool:
|
|
94
|
+
"""Ask the Airflow auth manager about a DAG-run action. Fails **closed**."""
|
|
95
|
+
try:
|
|
96
|
+
from airflow.api_fastapi.auth.managers.models.resource_details import (
|
|
97
|
+
DagAccessEntity,
|
|
98
|
+
DagDetails,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
manager = _resolve_auth_manager()
|
|
102
|
+
return bool(
|
|
103
|
+
manager.is_authorized_dag(
|
|
104
|
+
method=method,
|
|
105
|
+
access_entity=DagAccessEntity.RUN,
|
|
106
|
+
details=DagDetails(id=dag_id),
|
|
107
|
+
user=user,
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
except Exception:
|
|
111
|
+
_log.warning(
|
|
112
|
+
"DAG authorization check (%s) failed; denying", method, exc_info=True
|
|
113
|
+
)
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _resolve_auth_manager() -> Any:
|
|
118
|
+
"""Return the active Airflow auth manager across known import locations."""
|
|
119
|
+
for module_name in (
|
|
120
|
+
"airflow.api_fastapi.app",
|
|
121
|
+
"airflow.www.extensions.init_auth_manager",
|
|
122
|
+
):
|
|
123
|
+
try:
|
|
124
|
+
module = __import__(module_name, fromlist=["get_auth_manager"])
|
|
125
|
+
return module.get_auth_manager()
|
|
126
|
+
except Exception:
|
|
127
|
+
continue
|
|
128
|
+
raise RuntimeError("could not resolve the Airflow auth manager")
|
{airflow_pytest_plugin-0.1.0 → airflow_pytest_plugin-0.2.0}/src/airflow_pytest_plugin/config.py
RENAMED
|
@@ -12,14 +12,7 @@
|
|
|
12
12
|
# See the License for the specific language governing permissions and
|
|
13
13
|
# limitations under the License.
|
|
14
14
|
|
|
15
|
-
"""Resolve the report root
|
|
16
|
-
|
|
17
|
-
Precedence, highest first:
|
|
18
|
-
1. an explicit argument in code (handled by the caller);
|
|
19
|
-
2. ``AIRFLOW_PYTEST_REPORTS_ROOT`` (env);
|
|
20
|
-
3. ``[pytest_reports] reports_root`` (Airflow config);
|
|
21
|
-
4. the default ``/opt/airflow/pytest-reports``.
|
|
22
|
-
"""
|
|
15
|
+
"""Resolve the report root (env, then Airflow config, then default)."""
|
|
23
16
|
|
|
24
17
|
from __future__ import annotations
|
|
25
18
|
|
{airflow_pytest_plugin-0.1.0 → airflow_pytest_plugin-0.2.0}/src/airflow_pytest_plugin/layout.py
RENAMED
|
@@ -12,14 +12,7 @@
|
|
|
12
12
|
# See the License for the specific language governing permissions and
|
|
13
13
|
# limitations under the License.
|
|
14
14
|
|
|
15
|
-
"""The on-disk layout
|
|
16
|
-
|
|
17
|
-
Shared by the producer (which asks where to write) and the reader (where to read
|
|
18
|
-
a report, and how to discover all of them via :data:`META_FILENAME`), so the two
|
|
19
|
-
can never drift. The directory is a human-friendly container; the authoritative
|
|
20
|
-
identity lives in ``meta.json`` / the :class:`ReportRef` token, so the path
|
|
21
|
-
sanitisation below can be lossy without losing information.
|
|
22
|
-
"""
|
|
15
|
+
"""The on-disk layout: maps a report to its directory, shared by producer and reader."""
|
|
23
16
|
|
|
24
17
|
from __future__ import annotations
|
|
25
18
|
|
|
@@ -32,29 +25,26 @@ from .models import ReportRef
|
|
|
32
25
|
REPORT_FILENAME = "junit.xml"
|
|
33
26
|
#: The sidecar identity+summary file. Its presence marks a stored report.
|
|
34
27
|
META_FILENAME = "meta.json"
|
|
28
|
+
#: Subdir of a report holding raw Allure results (for Allure TestOps export).
|
|
29
|
+
ALLURE_DIRNAME = "allure-results"
|
|
35
30
|
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
# awkward or illegal on some filesystems. We map anything outside a conservative
|
|
39
|
-
# safe set to ``_``. This is intentionally lossy: the true, exact identity is
|
|
40
|
-
# preserved in meta.json / the ReportRef token, never recovered from the path.
|
|
31
|
+
# Map filesystem-unsafe chars to ``_``. Intentionally lossy; exact identity lives
|
|
32
|
+
# in meta.json / the ReportRef token, never recovered from the path.
|
|
41
33
|
_UNSAFE = re.compile(r"[^A-Za-z0-9._=+-]")
|
|
42
34
|
|
|
43
35
|
|
|
44
36
|
def _safe(component: str) -> str:
|
|
45
37
|
cleaned = _UNSAFE.sub("_", component)
|
|
46
|
-
#
|
|
47
|
-
|
|
38
|
+
# Neutralise empty / dot-only components so a path-traversal segment can never escape the root.
|
|
39
|
+
if not cleaned or set(cleaned) <= {"."}:
|
|
40
|
+
return "_"
|
|
41
|
+
return cleaned
|
|
48
42
|
|
|
49
43
|
|
|
50
44
|
class ReportLayout:
|
|
51
45
|
"""Maps a :class:`ReportRef` to its directory under a report root::
|
|
52
46
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
The ``m{map_index}`` segment is added only for mapped tasks. A pure function
|
|
56
|
-
of its inputs, so the reader can locate a report from a token without
|
|
57
|
-
scanning the filesystem.
|
|
47
|
+
{root}/{dag_id}/{run_id}/{task_id}/t{try_number}[/m{map_index}]
|
|
58
48
|
"""
|
|
59
49
|
|
|
60
50
|
def dir_for(self, root: str, ref: ReportRef) -> str:
|
{airflow_pytest_plugin-0.1.0 → airflow_pytest_plugin-0.2.0}/src/airflow_pytest_plugin/models.py
RENAMED
|
@@ -12,14 +12,7 @@
|
|
|
12
12
|
# See the License for the specific language governing permissions and
|
|
13
13
|
# limitations under the License.
|
|
14
14
|
|
|
15
|
-
"""
|
|
16
|
-
|
|
17
|
-
JSON-serializable domain types that sit above the operator's ``TestRunResult`` /
|
|
18
|
-
``CaseResult`` (used only for parsing), so the web layer never sees operator
|
|
19
|
-
types. A report is identified by its Airflow coordinates (:class:`ReportRef`);
|
|
20
|
-
its :pyattr:`token` is an opaque, reversible id used in the HTTP API, so an
|
|
21
|
-
endpoint can rebuild the ref (and on-disk location) without scanning.
|
|
22
|
-
"""
|
|
15
|
+
"""JSON-serializable view models for the reports UI."""
|
|
23
16
|
|
|
24
17
|
from __future__ import annotations
|
|
25
18
|
|
|
@@ -44,9 +37,8 @@ def _decode_token(token: str) -> dict[str, Any]:
|
|
|
44
37
|
class ReportRef:
|
|
45
38
|
"""The Airflow coordinates that uniquely identify one stored report.
|
|
46
39
|
|
|
47
|
-
``map_index`` is ``-1`` for
|
|
48
|
-
|
|
49
|
-
dynamically-mapped task.
|
|
40
|
+
``map_index`` is ``-1`` for a non-mapped task (Airflow's convention), ``>= 0``
|
|
41
|
+
for one expanded element of a mapped task.
|
|
50
42
|
"""
|
|
51
43
|
|
|
52
44
|
dag_id: str
|
|
@@ -57,7 +49,7 @@ class ReportRef:
|
|
|
57
49
|
|
|
58
50
|
@property
|
|
59
51
|
def token(self) -> str:
|
|
60
|
-
"""An opaque, URL-safe, reversible id for
|
|
52
|
+
"""An opaque, URL-safe, reversible id for the HTTP API."""
|
|
61
53
|
return _encode_token(
|
|
62
54
|
{
|
|
63
55
|
"d": self.dag_id,
|
|
@@ -73,12 +65,16 @@ class ReportRef:
|
|
|
73
65
|
"""Inverse of :pyattr:`token`. Raises ``ValueError`` on a bad token."""
|
|
74
66
|
try:
|
|
75
67
|
d = _decode_token(token)
|
|
68
|
+
try_number = int(d["n"])
|
|
69
|
+
map_index = int(d["m"])
|
|
70
|
+
if try_number < 0 or map_index < -1:
|
|
71
|
+
raise ValueError("out-of-range try_number/map_index")
|
|
76
72
|
return cls(
|
|
77
73
|
dag_id=str(d["d"]),
|
|
78
74
|
run_id=str(d["r"]),
|
|
79
75
|
task_id=str(d["t"]),
|
|
80
|
-
try_number=
|
|
81
|
-
map_index=
|
|
76
|
+
try_number=try_number,
|
|
77
|
+
map_index=map_index,
|
|
82
78
|
)
|
|
83
79
|
except (KeyError, ValueError, TypeError, json.JSONDecodeError) as exc:
|
|
84
80
|
raise ValueError(f"malformed report token: {token!r}") from exc
|
|
@@ -86,10 +82,7 @@ class ReportRef:
|
|
|
86
82
|
|
|
87
83
|
@dataclass(frozen=True)
|
|
88
84
|
class ReportSummary:
|
|
89
|
-
"""The headline numbers for one run
|
|
90
|
-
|
|
91
|
-
Built directly from ``meta.json`` so listing never has to parse XML.
|
|
92
|
-
"""
|
|
85
|
+
"""The headline numbers for one run, built from ``meta.json`` (no XML parse)."""
|
|
93
86
|
|
|
94
87
|
ref: ReportRef
|
|
95
88
|
total: int
|
|
@@ -101,6 +94,7 @@ class ReportSummary:
|
|
|
101
94
|
success: bool
|
|
102
95
|
created_at: str | None = None
|
|
103
96
|
logical_date: str | None = None
|
|
97
|
+
has_allure: bool = False
|
|
104
98
|
|
|
105
99
|
def to_dict(self) -> dict[str, Any]:
|
|
106
100
|
return {
|
|
@@ -119,6 +113,7 @@ class ReportSummary:
|
|
|
119
113
|
"success": self.success,
|
|
120
114
|
"created_at": self.created_at,
|
|
121
115
|
"logical_date": self.logical_date,
|
|
116
|
+
"has_allure": self.has_allure,
|
|
122
117
|
}
|
|
123
118
|
|
|
124
119
|
|