airflow-pytest-plugin 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,66 @@
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
+ """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
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from typing import TYPE_CHECKING
32
+
33
+ from .config import get_reports_root
34
+ from .layout import ReportLayout
35
+ from .models import CaseView, ReportDetail, ReportRef, ReportSummary
36
+ from .producer import ArchivingJUnitResultParser
37
+ from .sources import FileSystemReportSource, ReportSource
38
+ from .version import __version__ as __version__
39
+
40
+ 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).
44
+ from .web import create_app as create_app
45
+
46
+ __all__ = [
47
+ "ArchivingJUnitResultParser",
48
+ "ReportSource",
49
+ "FileSystemReportSource",
50
+ "ReportRef",
51
+ "ReportSummary",
52
+ "ReportDetail",
53
+ "CaseView",
54
+ "ReportLayout",
55
+ "get_reports_root",
56
+ "create_app",
57
+ "__version__",
58
+ ]
59
+
60
+
61
+ def __getattr__(name: str) -> object:
62
+ if name == "create_app":
63
+ from .web import create_app
64
+
65
+ return create_app
66
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,29 @@
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 compatibility surface (the only package that touches Airflow)."""
16
+
17
+ from __future__ import annotations
18
+
19
+ from .airflow import (
20
+ get_airflow_plugin_base,
21
+ get_conf_value,
22
+ get_current_context,
23
+ )
24
+
25
+ __all__ = [
26
+ "get_current_context",
27
+ "get_airflow_plugin_base",
28
+ "get_conf_value",
29
+ ]
@@ -0,0 +1,70 @@
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), never at module load, so the
18
+ producer parser can import the plugin on a worker without dragging in the Task
19
+ SDK. Airflow 2.x/3.x differences are resolved here, 3.x first, degrading
20
+ gracefully when Airflow is absent (unit tests, the dev server).
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from typing import Any, cast
26
+
27
+
28
+ def get_current_context() -> dict[str, Any] | None:
29
+ """Return the running task's Airflow context, or ``None`` if unavailable.
30
+
31
+ Tries the Airflow 3 Task SDK first, then the Airflow 2 location. ``None``
32
+ means no active context (the parser used off-task); callers fall back.
33
+ """
34
+ # Airflow 3.x (Task SDK).
35
+ try:
36
+ from airflow.sdk import get_current_context as _gcc3
37
+
38
+ return dict(_gcc3())
39
+ except Exception:
40
+ pass
41
+ # Airflow 2.x.
42
+ try:
43
+ from airflow.operators.python import get_current_context as _gcc2
44
+
45
+ return dict(_gcc2())
46
+ except Exception:
47
+ return None
48
+
49
+
50
+ def get_airflow_plugin_base() -> type[Any]:
51
+ """Return ``airflow.plugins_manager.AirflowPlugin`` (same path on 2.x/3.x).
52
+
53
+ Raises ``ImportError`` if Airflow is absent; the plugin module guards it.
54
+ """
55
+ from airflow.plugins_manager import AirflowPlugin
56
+
57
+ return cast("type[Any]", AirflowPlugin)
58
+
59
+
60
+ def get_conf_value(section: str, key: str) -> str | None:
61
+ """Read a value from ``airflow.cfg`` / env, or ``None`` if unset/absent."""
62
+ try:
63
+ from airflow.configuration import conf
64
+
65
+ # Annotate rather than cast: clean whether ``conf.get`` is typed as
66
+ # ``str | None`` (Airflow installed) or ``Any`` (not installed).
67
+ value: str | None = conf.get(section, key, fallback=None)
68
+ return value
69
+ except Exception:
70
+ return None
@@ -0,0 +1,46 @@
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 -- 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
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import os
27
+
28
+ from .compat import get_conf_value
29
+
30
+ ENV_VAR = "AIRFLOW_PYTEST_REPORTS_ROOT"
31
+ CONF_SECTION = "pytest_reports"
32
+ CONF_KEY = "reports_root"
33
+ DEFAULT_ROOT = "/opt/airflow/pytest-reports"
34
+
35
+
36
+ def get_reports_root() -> str:
37
+ """Resolve the report root directory (absolute path)."""
38
+ env = os.environ.get(ENV_VAR)
39
+ if env and env.strip():
40
+ return os.path.abspath(env.strip())
41
+
42
+ conf = get_conf_value(CONF_SECTION, CONF_KEY)
43
+ if conf and conf.strip():
44
+ return os.path.abspath(conf.strip())
45
+
46
+ return os.path.abspath(DEFAULT_ROOT)
@@ -0,0 +1,78 @@
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
+ """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
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import os
27
+ import re
28
+
29
+ from .models import ReportRef
30
+
31
+ #: The JUnit XML filename the producer writes and the reader parses.
32
+ REPORT_FILENAME = "junit.xml"
33
+ #: The sidecar identity+summary file. Its presence marks a stored report.
34
+ META_FILENAME = "meta.json"
35
+
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.
41
+ _UNSAFE = re.compile(r"[^A-Za-z0-9._=+-]")
42
+
43
+
44
+ def _safe(component: str) -> str:
45
+ cleaned = _UNSAFE.sub("_", component)
46
+ # Guard against empty / dot components producing a path that escapes root.
47
+ return cleaned or "_"
48
+
49
+
50
+ class ReportLayout:
51
+ """Maps a :class:`ReportRef` to its directory under a report root::
52
+
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.
58
+ """
59
+
60
+ def dir_for(self, root: str, ref: ReportRef) -> str:
61
+ parts = [
62
+ os.path.abspath(root),
63
+ _safe(ref.dag_id),
64
+ _safe(ref.run_id),
65
+ _safe(ref.task_id),
66
+ f"t{ref.try_number}",
67
+ ]
68
+ if ref.map_index >= 0:
69
+ parts.append(f"m{ref.map_index}")
70
+ return os.path.join(*parts)
71
+
72
+ def report_path(self, root: str, ref: ReportRef) -> str:
73
+ """Absolute path to the JUnit XML for ``ref``."""
74
+ return os.path.join(self.dir_for(root, ref), REPORT_FILENAME)
75
+
76
+ def meta_path(self, root: str, ref: ReportRef) -> str:
77
+ """Absolute path to the ``meta.json`` for ``ref``."""
78
+ return os.path.join(self.dir_for(root, ref), META_FILENAME)
@@ -0,0 +1,157 @@
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
+ """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
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import base64
27
+ import json
28
+ from dataclasses import dataclass, field
29
+ from typing import Any, cast
30
+
31
+
32
+ def _encode_token(payload: dict[str, Any]) -> str:
33
+ raw = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
34
+ return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
35
+
36
+
37
+ def _decode_token(token: str) -> dict[str, Any]:
38
+ pad = "=" * (-len(token) % 4)
39
+ raw = base64.urlsafe_b64decode(token + pad)
40
+ return cast("dict[str, Any]", json.loads(raw))
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class ReportRef:
45
+ """The Airflow coordinates that uniquely identify one stored report.
46
+
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.
50
+ """
51
+
52
+ dag_id: str
53
+ run_id: str
54
+ task_id: str
55
+ try_number: int
56
+ map_index: int = -1
57
+
58
+ @property
59
+ def token(self) -> str:
60
+ """An opaque, URL-safe, reversible id for use in the HTTP API."""
61
+ return _encode_token(
62
+ {
63
+ "d": self.dag_id,
64
+ "r": self.run_id,
65
+ "t": self.task_id,
66
+ "n": self.try_number,
67
+ "m": self.map_index,
68
+ }
69
+ )
70
+
71
+ @classmethod
72
+ def from_token(cls, token: str) -> ReportRef:
73
+ """Inverse of :pyattr:`token`. Raises ``ValueError`` on a bad token."""
74
+ try:
75
+ d = _decode_token(token)
76
+ return cls(
77
+ dag_id=str(d["d"]),
78
+ run_id=str(d["r"]),
79
+ task_id=str(d["t"]),
80
+ try_number=int(d["n"]),
81
+ map_index=int(d["m"]),
82
+ )
83
+ except (KeyError, ValueError, TypeError, json.JSONDecodeError) as exc:
84
+ raise ValueError(f"malformed report token: {token!r}") from exc
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ 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
+ """
93
+
94
+ ref: ReportRef
95
+ total: int
96
+ passed: int
97
+ failed: int
98
+ skipped: int
99
+ errors: int
100
+ duration: float
101
+ success: bool
102
+ created_at: str | None = None
103
+ logical_date: str | None = None
104
+
105
+ def to_dict(self) -> dict[str, Any]:
106
+ return {
107
+ "id": self.ref.token,
108
+ "dag_id": self.ref.dag_id,
109
+ "run_id": self.ref.run_id,
110
+ "task_id": self.ref.task_id,
111
+ "try_number": self.ref.try_number,
112
+ "map_index": self.ref.map_index,
113
+ "total": self.total,
114
+ "passed": self.passed,
115
+ "failed": self.failed,
116
+ "skipped": self.skipped,
117
+ "errors": self.errors,
118
+ "duration": self.duration,
119
+ "success": self.success,
120
+ "created_at": self.created_at,
121
+ "logical_date": self.logical_date,
122
+ }
123
+
124
+
125
+ @dataclass(frozen=True)
126
+ class CaseView:
127
+ """One test case, flattened for the detail table."""
128
+
129
+ node_id: str
130
+ name: str
131
+ classname: str
132
+ outcome: str # "passed" | "failed" | "error" | "skipped"
133
+ time: float
134
+ message: str | None = None
135
+
136
+ def to_dict(self) -> dict[str, Any]:
137
+ return {
138
+ "node_id": self.node_id,
139
+ "name": self.name,
140
+ "classname": self.classname,
141
+ "outcome": self.outcome,
142
+ "time": self.time,
143
+ "message": self.message,
144
+ }
145
+
146
+
147
+ @dataclass(frozen=True)
148
+ class ReportDetail:
149
+ """A summary plus its per-case rows -- the detail view's payload."""
150
+
151
+ summary: ReportSummary
152
+ cases: tuple[CaseView, ...] = field(default_factory=tuple)
153
+
154
+ def to_dict(self) -> dict[str, Any]:
155
+ data = self.summary.to_dict()
156
+ data["cases"] = [c.to_dict() for c in self.cases]
157
+ return data
@@ -0,0 +1,98 @@
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
+ """The Airflow plugin entry point.
16
+
17
+ Mounts the FastAPI app on Airflow 3's API server (``fastapi_apps``) and adds a
18
+ nav link that embeds it in an iframe (``external_views``). Both are best-effort:
19
+ missing FastAPI leaves the producer parser unaffected.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import logging
25
+ from typing import TYPE_CHECKING, Any
26
+
27
+ from .compat import get_airflow_plugin_base
28
+
29
+ _log = logging.getLogger(__name__)
30
+
31
+ #: Mount prefix on the API server. The viewer derives its API base from the URL,
32
+ #: so this can change without touching the page.
33
+ URL_PREFIX = "/pytest-reports"
34
+ #: Metadata name for the mounted app (not shown in the nav).
35
+ APP_NAME = "Pytest Reports"
36
+ #: The label shown in the Airflow left nav.
37
+ NAV_NAME = "Pytest"
38
+
39
+
40
+ def _build_fastapi_apps() -> list[dict[str, Any]]:
41
+ """Construct the ``fastapi_apps`` registration, or ``[]`` if unavailable."""
42
+ try:
43
+ from .sources import FileSystemReportSource
44
+ from .web import create_app
45
+
46
+ app = create_app(FileSystemReportSource())
47
+ except Exception: # FastAPI missing, or any construction error
48
+ _log.warning(
49
+ "Pytest Reports UI not registered (FastAPI unavailable or app build "
50
+ "failed); the producer-side parser is unaffected.",
51
+ exc_info=True,
52
+ )
53
+ return []
54
+ return [{"app": app, "url_prefix": URL_PREFIX, "name": APP_NAME}]
55
+
56
+
57
+ def _build_external_views() -> list[dict[str, Any]]:
58
+ """A nav link that embeds the mounted app in an iframe (Airflow 3.1+).
59
+
60
+ ``url_route`` makes Airflow render the view inline (an iframe whose ``src``
61
+ is ``href``); ``href`` MUST carry a trailing slash so it hits the mounted
62
+ app's index (``/pytest-reports/``) directly. Without it the bare prefix
63
+ falls through to the Airflow SPA, which renders its own chrome inside the
64
+ iframe (duplicated nav) and a 404 for the unknown client route.
65
+
66
+ ``icon`` / ``icon_dark_mode`` are URLs to SVGs the app itself serves (the
67
+ flask glyph), so the nav shows a colba icon that contrasts in both themes.
68
+ """
69
+ return [
70
+ {
71
+ "name": NAV_NAME,
72
+ "href": f"{URL_PREFIX}/",
73
+ "url_route": "pytest-reports",
74
+ "destination": "nav",
75
+ "icon": f"{URL_PREFIX}/icon.svg",
76
+ "icon_dark_mode": f"{URL_PREFIX}/icon-dark.svg",
77
+ }
78
+ ]
79
+
80
+
81
+ # mypy checks the plugin against a plain ``object`` base (the dynamic Airflow
82
+ # base is resolved only at runtime); this keeps the class definition type-clean
83
+ # whether or not Airflow is installed in the checking environment.
84
+ if TYPE_CHECKING:
85
+ _Base = object
86
+ else:
87
+ try:
88
+ _Base = get_airflow_plugin_base()
89
+ except Exception: # Airflow not installed -- allow import for unit tests
90
+ _Base = object
91
+
92
+
93
+ class PytestReportsPlugin(_Base):
94
+ """Exposes the Pytest Reports UI to Airflow."""
95
+
96
+ name = "pytest_reports"
97
+ fastapi_apps = _build_fastapi_apps()
98
+ external_views = _build_external_views()
@@ -0,0 +1,21 @@
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
+ """Producer side: archive JUnit reports into the queryable layout."""
16
+
17
+ from __future__ import annotations
18
+
19
+ from .archiving_parser import ArchivingJUnitResultParser
20
+
21
+ __all__ = ["ArchivingJUnitResultParser"]