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.
- airflow_pytest_plugin/__init__.py +66 -0
- airflow_pytest_plugin/compat/__init__.py +29 -0
- airflow_pytest_plugin/compat/airflow.py +70 -0
- airflow_pytest_plugin/config.py +46 -0
- airflow_pytest_plugin/layout.py +78 -0
- airflow_pytest_plugin/models.py +157 -0
- airflow_pytest_plugin/plugin.py +98 -0
- airflow_pytest_plugin/producer/__init__.py +21 -0
- airflow_pytest_plugin/producer/archiving_parser.py +214 -0
- airflow_pytest_plugin/sources/__init__.py +22 -0
- airflow_pytest_plugin/sources/base.py +49 -0
- airflow_pytest_plugin/sources/filesystem.py +231 -0
- airflow_pytest_plugin/version.py +31 -0
- airflow_pytest_plugin/web/__init__.py +21 -0
- airflow_pytest_plugin/web/__main__.py +53 -0
- airflow_pytest_plugin/web/app.py +103 -0
- airflow_pytest_plugin/web/templates.py +564 -0
- airflow_pytest_plugin-0.1.0.dist-info/METADATA +222 -0
- airflow_pytest_plugin-0.1.0.dist-info/RECORD +22 -0
- airflow_pytest_plugin-0.1.0.dist-info/WHEEL +4 -0
- airflow_pytest_plugin-0.1.0.dist-info/entry_points.txt +2 -0
- airflow_pytest_plugin-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -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"]
|