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.
Files changed (41) hide show
  1. {airflow_pytest_plugin-0.1.0 → airflow_pytest_plugin-0.2.0}/PKG-INFO +85 -7
  2. {airflow_pytest_plugin-0.1.0 → airflow_pytest_plugin-0.2.0}/README.md +84 -6
  3. {airflow_pytest_plugin-0.1.0 → airflow_pytest_plugin-0.2.0}/examples/example_dag.py +3 -3
  4. {airflow_pytest_plugin-0.1.0 → airflow_pytest_plugin-0.2.0}/pyproject.toml +1 -1
  5. {airflow_pytest_plugin-0.1.0 → airflow_pytest_plugin-0.2.0}/src/airflow_pytest_plugin/__init__.py +4 -18
  6. {airflow_pytest_plugin-0.1.0 → airflow_pytest_plugin-0.2.0}/src/airflow_pytest_plugin/compat/__init__.py +8 -0
  7. airflow_pytest_plugin-0.2.0/src/airflow_pytest_plugin/compat/airflow.py +128 -0
  8. {airflow_pytest_plugin-0.1.0 → airflow_pytest_plugin-0.2.0}/src/airflow_pytest_plugin/config.py +1 -8
  9. {airflow_pytest_plugin-0.1.0 → airflow_pytest_plugin-0.2.0}/src/airflow_pytest_plugin/layout.py +10 -20
  10. {airflow_pytest_plugin-0.1.0 → airflow_pytest_plugin-0.2.0}/src/airflow_pytest_plugin/models.py +13 -18
  11. {airflow_pytest_plugin-0.1.0 → airflow_pytest_plugin-0.2.0}/src/airflow_pytest_plugin/plugin.py +5 -22
  12. {airflow_pytest_plugin-0.1.0 → airflow_pytest_plugin-0.2.0}/src/airflow_pytest_plugin/producer/__init__.py +3 -3
  13. {airflow_pytest_plugin-0.1.0 → airflow_pytest_plugin-0.2.0}/src/airflow_pytest_plugin/producer/archiving_parser.py +76 -47
  14. {airflow_pytest_plugin-0.1.0 → airflow_pytest_plugin-0.2.0}/src/airflow_pytest_plugin/sources/base.py +15 -12
  15. {airflow_pytest_plugin-0.1.0 → airflow_pytest_plugin-0.2.0}/src/airflow_pytest_plugin/sources/filesystem.py +100 -51
  16. {airflow_pytest_plugin-0.1.0 → airflow_pytest_plugin-0.2.0}/src/airflow_pytest_plugin/version.py +1 -1
  17. airflow_pytest_plugin-0.2.0/src/airflow_pytest_plugin/web/app.py +184 -0
  18. airflow_pytest_plugin-0.2.0/src/airflow_pytest_plugin/web/templates.py +1468 -0
  19. {airflow_pytest_plugin-0.1.0 → airflow_pytest_plugin-0.2.0}/tests/conftest.py +52 -2
  20. airflow_pytest_plugin-0.2.0/tests/test_archiving_parser.py +237 -0
  21. airflow_pytest_plugin-0.2.0/tests/test_authz_integration.py +113 -0
  22. airflow_pytest_plugin-0.2.0/tests/test_compat.py +129 -0
  23. airflow_pytest_plugin-0.2.0/tests/test_config.py +42 -0
  24. airflow_pytest_plugin-0.2.0/tests/test_filesystem_source.py +336 -0
  25. {airflow_pytest_plugin-0.1.0 → airflow_pytest_plugin-0.2.0}/tests/test_layout.py +6 -0
  26. {airflow_pytest_plugin-0.1.0 → airflow_pytest_plugin-0.2.0}/tests/test_models.py +10 -0
  27. airflow_pytest_plugin-0.2.0/tests/test_package.py +30 -0
  28. {airflow_pytest_plugin-0.1.0 → airflow_pytest_plugin-0.2.0}/tests/test_plugin.py +2 -5
  29. airflow_pytest_plugin-0.2.0/tests/test_version.py +31 -0
  30. airflow_pytest_plugin-0.2.0/tests/test_web_app.py +264 -0
  31. airflow_pytest_plugin-0.1.0/src/airflow_pytest_plugin/compat/airflow.py +0 -70
  32. airflow_pytest_plugin-0.1.0/src/airflow_pytest_plugin/web/app.py +0 -103
  33. airflow_pytest_plugin-0.1.0/src/airflow_pytest_plugin/web/templates.py +0 -564
  34. airflow_pytest_plugin-0.1.0/tests/test_archiving_parser.py +0 -81
  35. airflow_pytest_plugin-0.1.0/tests/test_filesystem_source.py +0 -104
  36. airflow_pytest_plugin-0.1.0/tests/test_web_app.py +0 -79
  37. {airflow_pytest_plugin-0.1.0 → airflow_pytest_plugin-0.2.0}/.gitignore +0 -0
  38. {airflow_pytest_plugin-0.1.0 → airflow_pytest_plugin-0.2.0}/LICENSE +0 -0
  39. {airflow_pytest_plugin-0.1.0 → airflow_pytest_plugin-0.2.0}/src/airflow_pytest_plugin/sources/__init__.py +0 -0
  40. {airflow_pytest_plugin-0.1.0 → airflow_pytest_plugin-0.2.0}/src/airflow_pytest_plugin/web/__init__.py +0 -0
  41. {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.1.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 | `ArchivingJUnitResultParser`, a drop-in `parser=` for `PytestOperator` |
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
+ ![Pytest Reports — overview](https://raw.githubusercontent.com/IKrysanov/airflow-pytest-plugin/main/docs/screenshots/overview.png)
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
+ ![Pytest Reports — a single run](https://raw.githubusercontent.com/IKrysanov/airflow-pytest-plugin/main/docs/screenshots/detail.png)
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 ArchivingJUnitResultParser
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=ArchivingJUnitResultParser(), # was JUnitResultParser()
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**. `ArchivingJUnitResultParser` supplies its own directory, so reports
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
- └─ ArchivingJUnitResultParser ──▶ {task}/t{try}/ ◀──── FileSystemReportSource
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.ArchivingJUnitResultParser` | write JUnit XML + `meta.json` (extends the operator's parser) |
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 | `ArchivingJUnitResultParser`, a drop-in `parser=` for `PytestOperator` |
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
+ ![Pytest Reports — overview](https://raw.githubusercontent.com/IKrysanov/airflow-pytest-plugin/main/docs/screenshots/overview.png)
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
+ ![Pytest Reports — a single run](https://raw.githubusercontent.com/IKrysanov/airflow-pytest-plugin/main/docs/screenshots/detail.png)
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 ArchivingJUnitResultParser
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=ArchivingJUnitResultParser(), # was JUnitResultParser()
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**. `ArchivingJUnitResultParser` supplies its own directory, so reports
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
- └─ ArchivingJUnitResultParser ──▶ {task}/t{try}/ ◀──── FileSystemReportSource
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.ArchivingJUnitResultParser` | write JUnit XML + `meta.json` (extends the operator's parser) |
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 ``ArchivingJUnitResultParser`` and every run is
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 ArchivingJUnitResultParser
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=ArchivingJUnitResultParser(),
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,
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "airflow-pytest-plugin"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "View airflow-pytest-operator results in the Airflow 3 web UI."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -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 ArchivingJUnitResultParser
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 eagerly
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
- "ArchivingJUnitResultParser",
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")
@@ -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 -- the same way on producer and reader.
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
 
@@ -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 -- the one place that knows where a report lives.
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
- # Filesystem-safe component encoder. Airflow ``run_id`` values routinely carry
37
- # ``:`` and ``+`` (e.g. ``scheduled__2024-01-01T00:00:00+00:00``), which are
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
- # Guard against empty / dot components producing a path that escapes root.
47
- return cleaned or "_"
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
- {root}/{dag_id}/{run_id}/{task_id}/t{try_number}[/m{map_index}]
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:
@@ -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
- """View models for the reports UI.
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 an ordinary (non-mapped) task instance, matching
48
- Airflow's own convention, and ``>= 0`` for a single expanded element of a
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 use in the HTTP API."""
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=int(d["n"]),
81
- map_index=int(d["m"]),
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 -- enough to render a list row.
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