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.
@@ -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