airflow-pytest-operator 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_operator/__init__.py +70 -0
- airflow_pytest_operator/compat/__init__.py +17 -0
- airflow_pytest_operator/compat/airflow.py +101 -0
- airflow_pytest_operator/exceptions.py +58 -0
- airflow_pytest_operator/models.py +95 -0
- airflow_pytest_operator/operators/__init__.py +17 -0
- airflow_pytest_operator/operators/pytest_operator.py +192 -0
- airflow_pytest_operator/reporters/__init__.py +18 -0
- airflow_pytest_operator/reporters/base.py +43 -0
- airflow_pytest_operator/reporters/junit_parser.py +123 -0
- airflow_pytest_operator/runners/__init__.py +18 -0
- airflow_pytest_operator/runners/base.py +80 -0
- airflow_pytest_operator/runners/subprocess_runner.py +292 -0
- airflow_pytest_operator-0.1.0.dist-info/METADATA +202 -0
- airflow_pytest_operator-0.1.0.dist-info/RECORD +18 -0
- airflow_pytest_operator-0.1.0.dist-info/WHEEL +4 -0
- airflow_pytest_operator-0.1.0.dist-info/entry_points.txt +2 -0
- airflow_pytest_operator-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Run pytest suites as Airflow tasks.
|
|
2
|
+
|
|
3
|
+
Public API:
|
|
4
|
+
PytestOperator — the operator to use in DAGs
|
|
5
|
+
PytestRunner — runner interface (extend for docker/k8s)
|
|
6
|
+
SubprocessPytestRunner — default runner
|
|
7
|
+
ResultParser — parser interface
|
|
8
|
+
JUnitResultParser — default parser
|
|
9
|
+
TestRunResult — structured result model
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
# Copyright 2026 Ilya Krysanov
|
|
13
|
+
#
|
|
14
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
15
|
+
# you may not use this file except in compliance with the License.
|
|
16
|
+
# You may obtain a copy of the License at
|
|
17
|
+
#
|
|
18
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
19
|
+
#
|
|
20
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
21
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
22
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
23
|
+
# See the License for the specific language governing permissions and
|
|
24
|
+
# limitations under the License.
|
|
25
|
+
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from .exceptions import (
|
|
29
|
+
AirflowPytestError,
|
|
30
|
+
ReportParseError,
|
|
31
|
+
TestExecutionError,
|
|
32
|
+
TestsFailedError,
|
|
33
|
+
)
|
|
34
|
+
from .models import CaseResult, RunArtifacts, TestRunResult
|
|
35
|
+
from .operators import PytestOperator
|
|
36
|
+
from .reporters import JUnitResultParser, ResultParser
|
|
37
|
+
from .runners import PytestRunner, SubprocessPytestRunner
|
|
38
|
+
|
|
39
|
+
__version__ = "0.1.0"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_provider_info() -> dict[str, Any]:
|
|
43
|
+
"""Metadata for Airflow's provider-discovery mechanism.
|
|
44
|
+
|
|
45
|
+
Lets Airflow's CLI/UI list this package as a provider. Optional —
|
|
46
|
+
operators work via plain imports regardless — but it makes the
|
|
47
|
+
package a well-behaved citizen if published.
|
|
48
|
+
"""
|
|
49
|
+
return {
|
|
50
|
+
"package-name": "airflow-pytest-operator",
|
|
51
|
+
"name": "Pytest Operator",
|
|
52
|
+
"description": "Run pytest suites as Airflow tasks.",
|
|
53
|
+
"versions": [__version__],
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
__all__ = [
|
|
58
|
+
"PytestOperator",
|
|
59
|
+
"PytestRunner",
|
|
60
|
+
"SubprocessPytestRunner",
|
|
61
|
+
"ResultParser",
|
|
62
|
+
"JUnitResultParser",
|
|
63
|
+
"TestRunResult",
|
|
64
|
+
"RunArtifacts",
|
|
65
|
+
"CaseResult",
|
|
66
|
+
"AirflowPytestError",
|
|
67
|
+
"TestExecutionError",
|
|
68
|
+
"ReportParseError",
|
|
69
|
+
"TestsFailedError",
|
|
70
|
+
]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Copyright 2026 Ilya Krysanov
|
|
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
|
+
from .airflow import BaseOperator, apply_defaults, get_airflow_version
|
|
16
|
+
|
|
17
|
+
__all__ = ["BaseOperator", "apply_defaults", "get_airflow_version"]
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Airflow version compatibility shim.
|
|
2
|
+
|
|
3
|
+
This is the *only* module in the package that imports Airflow directly.
|
|
4
|
+
Everything else imports ``BaseOperator`` from here. Centralizing the
|
|
5
|
+
version-specific imports means that supporting a new Airflow release is
|
|
6
|
+
a one-file change (Open/Closed at the package level).
|
|
7
|
+
|
|
8
|
+
Airflow 2.x and 3.x differ in the import path of ``BaseOperator`` and a
|
|
9
|
+
few helpers. We resolve them once, lazily, and expose a stable surface.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
# Copyright 2026 Ilya Krysanov
|
|
13
|
+
#
|
|
14
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
15
|
+
# you may not use this file except in compliance with the License.
|
|
16
|
+
# You may obtain a copy of the License at
|
|
17
|
+
#
|
|
18
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
19
|
+
#
|
|
20
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
21
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
22
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
23
|
+
# See the License for the specific language governing permissions and
|
|
24
|
+
# limitations under the License.
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from functools import lru_cache
|
|
29
|
+
from typing import TYPE_CHECKING, Any
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
# Static analysers (mypy/Pylance) don't have Airflow installed and
|
|
33
|
+
# can't follow the runtime try/except below. We give them a concrete
|
|
34
|
+
# but minimal type to bind ``BaseOperator`` to, so downstream code is
|
|
35
|
+
# type-checkable. At runtime this block is skipped entirely.
|
|
36
|
+
class BaseOperator: # noqa: D101 - stub for type-checking only
|
|
37
|
+
task_id: str
|
|
38
|
+
log: Any
|
|
39
|
+
|
|
40
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None: ...
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@lru_cache(maxsize=1)
|
|
44
|
+
def get_airflow_version() -> tuple[int, ...]:
|
|
45
|
+
try:
|
|
46
|
+
from airflow.version import version as _v
|
|
47
|
+
except Exception: # pragma: no cover - airflow always ships this
|
|
48
|
+
return (0,)
|
|
49
|
+
parts: list[int] = []
|
|
50
|
+
for chunk in _v.split(".")[:3]:
|
|
51
|
+
num = "".join(ch for ch in chunk if ch.isdigit())
|
|
52
|
+
parts.append(int(num) if num else 0)
|
|
53
|
+
return tuple(parts)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _import_base_operator() -> type[Any]:
|
|
57
|
+
"""Return the correct BaseOperator class for the installed Airflow.
|
|
58
|
+
|
|
59
|
+
Airflow 3 moved BaseOperator to ``airflow.sdk``. Airflow 2 keeps it at
|
|
60
|
+
``airflow.models.baseoperator``. We try the new location first and
|
|
61
|
+
fall back, so a single wheel works against both.
|
|
62
|
+
"""
|
|
63
|
+
try:
|
|
64
|
+
# Airflow 3.x (Task SDK)
|
|
65
|
+
from airflow.sdk import BaseOperator # type: ignore[attr-defined]
|
|
66
|
+
|
|
67
|
+
return BaseOperator # type: ignore[no-any-return]
|
|
68
|
+
except Exception:
|
|
69
|
+
pass
|
|
70
|
+
from airflow.models.baseoperator import BaseOperator # Airflow 2.x
|
|
71
|
+
|
|
72
|
+
return BaseOperator # type: ignore[no-any-return]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _import_apply_defaults() -> Any:
|
|
76
|
+
"""``apply_defaults`` is a no-op decorator in 2.x and gone in 3.x.
|
|
77
|
+
|
|
78
|
+
We return a passthrough when it's unavailable so operator code can
|
|
79
|
+
reference it uniformly without branching.
|
|
80
|
+
"""
|
|
81
|
+
try:
|
|
82
|
+
from airflow.utils.decorators import ( # type: ignore[attr-defined]
|
|
83
|
+
apply_defaults,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
return apply_defaults
|
|
87
|
+
except Exception:
|
|
88
|
+
|
|
89
|
+
def apply_defaults(func: Any) -> Any:
|
|
90
|
+
return func
|
|
91
|
+
|
|
92
|
+
return apply_defaults
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# Resolved at import time, but cheap and side-effect-free.
|
|
96
|
+
# The TYPE_CHECKING stub above already bound ``BaseOperator`` for analysers;
|
|
97
|
+
# this is the real runtime value, hence the explicit no-redef suppression.
|
|
98
|
+
BaseOperator = _import_base_operator() # type: ignore[assignment,no-redef,misc]
|
|
99
|
+
apply_defaults = _import_apply_defaults()
|
|
100
|
+
|
|
101
|
+
__all__ = ["BaseOperator", "apply_defaults", "get_airflow_version"]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Exception hierarchy for the operator.
|
|
2
|
+
|
|
3
|
+
A small, focused hierarchy lets callers (and Airflow's retry logic)
|
|
4
|
+
distinguish *test failures* from *infrastructure failures*. That
|
|
5
|
+
distinction matters: a failing test usually shouldn't be retried,
|
|
6
|
+
but a missing pytest binary or unreadable report might be.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
# Copyright 2026 Ilya Krysanov
|
|
10
|
+
#
|
|
11
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
12
|
+
# you may not use this file except in compliance with the License.
|
|
13
|
+
# You may obtain a copy of the License at
|
|
14
|
+
#
|
|
15
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
16
|
+
#
|
|
17
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
18
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
19
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
20
|
+
# See the License for the specific language governing permissions and
|
|
21
|
+
# limitations under the License.
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from typing import TYPE_CHECKING
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from .models import TestRunResult
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AirflowPytestError(Exception):
|
|
32
|
+
"""Base class for all errors raised by this package."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TestExecutionError(AirflowPytestError):
|
|
36
|
+
"""The runner could not execute pytest at all (binary missing, etc.)."""
|
|
37
|
+
|
|
38
|
+
__test__ = False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ReportParseError(AirflowPytestError):
|
|
42
|
+
"""A report file was produced but could not be parsed."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class TestsFailedError(AirflowPytestError):
|
|
46
|
+
"""Pytest ran successfully but one or more tests failed.
|
|
47
|
+
|
|
48
|
+
Carries the structured result so downstream handlers can inspect it.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
__test__ = False
|
|
52
|
+
|
|
53
|
+
def __init__(self, result: TestRunResult) -> None:
|
|
54
|
+
self.result = result
|
|
55
|
+
super().__init__(
|
|
56
|
+
f"{result.failed} failed, {result.errors} errors "
|
|
57
|
+
f"out of {result.total} tests"
|
|
58
|
+
)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Domain models for test runs.
|
|
2
|
+
|
|
3
|
+
These are plain, framework-agnostic dataclasses. Nothing here imports
|
|
4
|
+
Airflow, pytest, or subprocess — keeping the domain layer dependency-free
|
|
5
|
+
makes it trivial to unit-test parsers and the operator in isolation (DIP).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
# Copyright 2026 Ilya Krysanov
|
|
9
|
+
#
|
|
10
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
11
|
+
# you may not use this file except in compliance with the License.
|
|
12
|
+
# You may obtain a copy of the License at
|
|
13
|
+
#
|
|
14
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
15
|
+
#
|
|
16
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
17
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
18
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
19
|
+
# See the License for the specific language governing permissions and
|
|
20
|
+
# limitations under the License.
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from dataclasses import asdict, dataclass, field
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class CaseResult:
|
|
30
|
+
"""A single test case outcome parsed from a report."""
|
|
31
|
+
|
|
32
|
+
name: str
|
|
33
|
+
classname: str
|
|
34
|
+
time: float
|
|
35
|
+
outcome: str # "passed" | "failed" | "error" | "skipped"
|
|
36
|
+
message: str | None = None
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def node_id(self) -> str:
|
|
40
|
+
"""Reconstruct a pytest-style node id when possible."""
|
|
41
|
+
if self.classname:
|
|
42
|
+
return f"{self.classname}::{self.name}"
|
|
43
|
+
return self.name
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class RunArtifacts:
|
|
48
|
+
"""Everything a Runner produces: where to find outputs + the exit code.
|
|
49
|
+
|
|
50
|
+
A Runner's job ends at producing files; interpreting them is the
|
|
51
|
+
Parser's job. This separation is what lets us swap runners
|
|
52
|
+
(subprocess, docker, k8s-pod) without touching parsing logic.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
exit_code: int
|
|
56
|
+
junit_xml_path: str | None
|
|
57
|
+
stdout: str = ""
|
|
58
|
+
stderr: str = ""
|
|
59
|
+
working_dir: str | None = None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class TestRunResult:
|
|
64
|
+
"""Structured, serializable result of a pytest run."""
|
|
65
|
+
|
|
66
|
+
__test__ = False # not a pytest test class despite the name
|
|
67
|
+
|
|
68
|
+
total: int
|
|
69
|
+
passed: int
|
|
70
|
+
failed: int
|
|
71
|
+
skipped: int
|
|
72
|
+
errors: int
|
|
73
|
+
duration: float
|
|
74
|
+
exit_code: int
|
|
75
|
+
cases: list[CaseResult] = field(default_factory=list)
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def success(self) -> bool:
|
|
79
|
+
return self.failed == 0 and self.errors == 0
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def failed_node_ids(self) -> list[str]:
|
|
83
|
+
return [c.node_id for c in self.cases if c.outcome in ("failed", "error")]
|
|
84
|
+
|
|
85
|
+
def to_xcom(self) -> dict[str, Any]:
|
|
86
|
+
"""A compact, JSON-serializable dict suitable for XCom.
|
|
87
|
+
|
|
88
|
+
We deliberately drop per-case ``message`` blobs from the summary
|
|
89
|
+
pushed to XCom (they can be huge); full detail stays in logs.
|
|
90
|
+
"""
|
|
91
|
+
data = asdict(self)
|
|
92
|
+
data.pop("cases", None)
|
|
93
|
+
data["success"] = self.success
|
|
94
|
+
data["failed_node_ids"] = self.failed_node_ids
|
|
95
|
+
return data
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Copyright 2026 Ilya Krysanov
|
|
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
|
+
from .pytest_operator import PytestOperator
|
|
16
|
+
|
|
17
|
+
__all__ = ["PytestOperator"]
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""The Airflow operator.
|
|
2
|
+
|
|
3
|
+
This class is intentionally *thin*. Its only responsibilities are:
|
|
4
|
+
1. orchestrate runner -> parser,
|
|
5
|
+
2. integrate with Airflow (templating, XCom, logging, fail policy).
|
|
6
|
+
|
|
7
|
+
It contains no subprocess logic and no XML parsing. Both collaborators
|
|
8
|
+
are injected (Dependency Inversion): defaults are provided for ergonomic
|
|
9
|
+
use in DAGs, but tests can pass fakes, and advanced users can swap in a
|
|
10
|
+
Docker/K8s runner or a JSON parser without subclassing.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
# Copyright 2026 Ilya Krysanov
|
|
14
|
+
#
|
|
15
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
16
|
+
# you may not use this file except in compliance with the License.
|
|
17
|
+
# You may obtain a copy of the License at
|
|
18
|
+
#
|
|
19
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
20
|
+
#
|
|
21
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
22
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
23
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
24
|
+
# See the License for the specific language governing permissions and
|
|
25
|
+
# limitations under the License.
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from collections.abc import Sequence
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
from ..compat import BaseOperator
|
|
33
|
+
from ..exceptions import TestExecutionError, TestsFailedError
|
|
34
|
+
from ..reporters import JUnitResultParser, ResultParser
|
|
35
|
+
from ..runners import PytestRunner, SubprocessPytestRunner
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class PytestOperator(BaseOperator):
|
|
39
|
+
"""Run a pytest suite as an Airflow task.
|
|
40
|
+
|
|
41
|
+
:param test_path: file or directory to pass to pytest (templated).
|
|
42
|
+
:param pytest_args: extra CLI args, e.g. ``["-k", "smoke", "-x"]`` (templated).
|
|
43
|
+
:param env: extra environment variables for the run (templated).
|
|
44
|
+
:param fail_on_test_failure: if True (default) the task fails when any
|
|
45
|
+
test fails or errors; if False the task always succeeds and the
|
|
46
|
+
outcome is only reflected in XCom.
|
|
47
|
+
:param push_result: if True (default) push the structured summary dict
|
|
48
|
+
to XCom under the ``pytest_result`` key. If False, nothing is sent
|
|
49
|
+
to XCom at all -- this also disables Airflow's automatic push of
|
|
50
|
+
the task's return value (``do_xcom_push`` is forced off).
|
|
51
|
+
:param runner: injectable :class:`PytestRunner` (default: subprocess).
|
|
52
|
+
:param parser: injectable :class:`ResultParser` (default: JUnit).
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
# Airflow Jinja-templates these attributes before execute() runs.
|
|
56
|
+
template_fields: Sequence[str] = ("test_path", "pytest_args", "env")
|
|
57
|
+
ui_color = "#4caf50"
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
*,
|
|
62
|
+
test_path: str,
|
|
63
|
+
pytest_args: Sequence[str] | None = None,
|
|
64
|
+
env: dict[str, str] | None = None,
|
|
65
|
+
fail_on_test_failure: bool = True,
|
|
66
|
+
push_result: bool = True,
|
|
67
|
+
runner: PytestRunner | None = None,
|
|
68
|
+
parser: ResultParser | None = None,
|
|
69
|
+
**kwargs: Any,
|
|
70
|
+
) -> None:
|
|
71
|
+
super().__init__(**kwargs)
|
|
72
|
+
self.test_path = test_path
|
|
73
|
+
self.pytest_args = list(pytest_args) if pytest_args else []
|
|
74
|
+
self.env = env or {}
|
|
75
|
+
self.fail_on_test_failure = fail_on_test_failure
|
|
76
|
+
self.push_result = push_result
|
|
77
|
+
if not push_result:
|
|
78
|
+
# push_result=False means "nothing in XCom at all". Airflow
|
|
79
|
+
# otherwise auto-pushes execute()'s return value under the
|
|
80
|
+
# "return_value" key when do_xcom_push is True (the default),
|
|
81
|
+
# so we disable that here to honour the intent. This wins over
|
|
82
|
+
# an explicit do_xcom_push=True in kwargs by design: choosing
|
|
83
|
+
# push_result=False is the more specific, intentional signal.
|
|
84
|
+
self.do_xcom_push = False
|
|
85
|
+
# DI with sensible defaults — collaborators, not inheritance.
|
|
86
|
+
self._runner = runner or SubprocessPytestRunner()
|
|
87
|
+
self._parser = parser or JUnitResultParser()
|
|
88
|
+
|
|
89
|
+
def execute(self, context: Any) -> dict[str, Any] | None:
|
|
90
|
+
self.log.info("Running pytest on %s", self.test_path)
|
|
91
|
+
|
|
92
|
+
artifacts = self._runner.run(
|
|
93
|
+
self.test_path,
|
|
94
|
+
pytest_args=self.pytest_args,
|
|
95
|
+
env=self.env,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
# Surface child output in the task log regardless of outcome.
|
|
99
|
+
if artifacts.stdout:
|
|
100
|
+
self.log.info("pytest stdout:\n%s", artifacts.stdout)
|
|
101
|
+
if artifacts.stderr:
|
|
102
|
+
self.log.warning("pytest stderr:\n%s", artifacts.stderr)
|
|
103
|
+
|
|
104
|
+
# ``run_ok`` drives the runner's cleanup policy ("on_success").
|
|
105
|
+
# It stays False until we've parsed a report whose tests all passed,
|
|
106
|
+
# so any early exit (missing report, failing tests) is treated as a
|
|
107
|
+
# failure and artifacts can be retained for post-mortem.
|
|
108
|
+
run_ok = False
|
|
109
|
+
try:
|
|
110
|
+
# No report means pytest never got far enough to write one
|
|
111
|
+
# (collection error, internal crash, OOM kill, wrong path).
|
|
112
|
+
# This is an *execution* failure, not a test failure -- surface
|
|
113
|
+
# it clearly with the captured stderr, not a cryptic parse error.
|
|
114
|
+
if artifacts.junit_xml_path is None:
|
|
115
|
+
raise TestExecutionError(
|
|
116
|
+
"pytest produced no JUnit report "
|
|
117
|
+
f"(exit code {artifacts.exit_code}). "
|
|
118
|
+
"This usually means a collection error or crash before "
|
|
119
|
+
"any test ran. Captured stderr:\n"
|
|
120
|
+
f"{artifacts.stderr or '<empty>'}"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
result = self._parser.parse(
|
|
124
|
+
artifacts.junit_xml_path, exit_code=artifacts.exit_code
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
self.log.info(
|
|
128
|
+
"Results: total=%d passed=%d failed=%d errors=%d skipped=%d (%.2fs)",
|
|
129
|
+
result.total,
|
|
130
|
+
result.passed,
|
|
131
|
+
result.failed,
|
|
132
|
+
result.errors,
|
|
133
|
+
result.skipped,
|
|
134
|
+
result.duration,
|
|
135
|
+
)
|
|
136
|
+
if result.failed_node_ids:
|
|
137
|
+
self.log.error(
|
|
138
|
+
"Failed tests:\n %s", "\n ".join(result.failed_node_ids)
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
summary = result.to_xcom() if self.push_result else None
|
|
142
|
+
if self.push_result:
|
|
143
|
+
# Returning a value auto-pushes to XCom under the default
|
|
144
|
+
# key; we also push an explicit, named key for stable
|
|
145
|
+
# downstream references.
|
|
146
|
+
context["ti"].xcom_push(key="pytest_result", value=summary)
|
|
147
|
+
|
|
148
|
+
run_ok = result.success
|
|
149
|
+
if self.fail_on_test_failure and not result.success:
|
|
150
|
+
raise TestsFailedError(result)
|
|
151
|
+
|
|
152
|
+
return summary
|
|
153
|
+
finally:
|
|
154
|
+
# Always invoke cleanup; the runner decides what to remove
|
|
155
|
+
# based on its policy and the success flag. Never let cleanup
|
|
156
|
+
# errors mask the real outcome of execute().
|
|
157
|
+
try:
|
|
158
|
+
self._runner.cleanup(success=run_ok)
|
|
159
|
+
except Exception: # pragma: no cover - best-effort teardown
|
|
160
|
+
self.log.exception("Error while cleaning up report directory")
|
|
161
|
+
|
|
162
|
+
def on_kill(self) -> None:
|
|
163
|
+
"""Abort the test run when Airflow terminates the task.
|
|
164
|
+
|
|
165
|
+
Airflow calls this when the task is killed -- execution timeout,
|
|
166
|
+
a manual clear/mark-failed, or the worker shutting down (SIGTERM).
|
|
167
|
+
We delegate to the runner, which owns the actual process/resource;
|
|
168
|
+
the operator deliberately knows nothing about subprocesses.
|
|
169
|
+
|
|
170
|
+
Delegation keeps responsibilities separate: the operator handles
|
|
171
|
+
the Airflow lifecycle, the runner handles teardown of whatever it
|
|
172
|
+
spawned. Runners that have nothing to cancel inherit a safe no-op.
|
|
173
|
+
"""
|
|
174
|
+
self.log.warning("Task killed -- cancelling pytest run on %s", self.test_path)
|
|
175
|
+
try:
|
|
176
|
+
self._runner.cancel()
|
|
177
|
+
except Exception: # pragma: no cover - best-effort teardown
|
|
178
|
+
# on_kill must never raise: it runs during teardown, and an
|
|
179
|
+
# exception here can mask the original termination cause and
|
|
180
|
+
# leave the task in a confusing state.
|
|
181
|
+
self.log.exception("Error while cancelling pytest run")
|
|
182
|
+
|
|
183
|
+
# A killed run is never successful, but with the default "always"
|
|
184
|
+
# policy the temp report dir is still removed -- kills/timeouts are
|
|
185
|
+
# exactly when leaked dirs pile up, so we must clean here too. The
|
|
186
|
+
# cancel() above has already stopped the process, so nothing is
|
|
187
|
+
# still writing into the directory. cleanup() is idempotent and
|
|
188
|
+
# thread-safe, so racing with execute()'s own finally is harmless.
|
|
189
|
+
try:
|
|
190
|
+
self._runner.cleanup(success=False)
|
|
191
|
+
except Exception: # pragma: no cover - best-effort teardown
|
|
192
|
+
self.log.exception("Error while cleaning up report directory")
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Copyright 2026 Ilya Krysanov
|
|
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
|
+
from .base import ResultParser
|
|
16
|
+
from .junit_parser import JUnitResultParser
|
|
17
|
+
|
|
18
|
+
__all__ = ["ResultParser", "JUnitResultParser"]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""The result-parser interface.
|
|
2
|
+
|
|
3
|
+
A parser turns a report file into a :class:`TestRunResult`. It knows
|
|
4
|
+
nothing about how the report was produced. Keeping this separate from
|
|
5
|
+
the runner means we can support other report formats (e.g. a JSON
|
|
6
|
+
report plugin) by adding a parser, not by editing existing code (OCP).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
# Copyright 2026 Ilya Krysanov
|
|
10
|
+
#
|
|
11
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
12
|
+
# you may not use this file except in compliance with the License.
|
|
13
|
+
# You may obtain a copy of the License at
|
|
14
|
+
#
|
|
15
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
16
|
+
#
|
|
17
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
18
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
19
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
20
|
+
# See the License for the specific language governing permissions and
|
|
21
|
+
# limitations under the License.
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from abc import ABC, abstractmethod
|
|
26
|
+
|
|
27
|
+
from ..models import TestRunResult
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ResultParser(ABC):
|
|
31
|
+
"""Parses a report file into a structured result."""
|
|
32
|
+
|
|
33
|
+
@abstractmethod
|
|
34
|
+
def parse(self, report_path: str, *, exit_code: int = 0) -> TestRunResult:
|
|
35
|
+
"""Parse ``report_path`` into a :class:`TestRunResult`.
|
|
36
|
+
|
|
37
|
+
``exit_code`` is threaded through so the result records how the
|
|
38
|
+
process actually terminated (a parser can't always infer e.g.
|
|
39
|
+
an internal pytest error from the XML alone).
|
|
40
|
+
|
|
41
|
+
Raises :class:`ReportParseError` if the file is missing or malformed.
|
|
42
|
+
"""
|
|
43
|
+
raise NotImplementedError
|