pytest-receptor 1.0.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.
- pytest_receptor/__init__.py +33 -0
- pytest_receptor/_version.py +1 -0
- pytest_receptor/artifact.py +295 -0
- pytest_receptor/model.py +182 -0
- pytest_receptor/plugin.py +1902 -0
- pytest_receptor-1.0.0.dist-info/METADATA +440 -0
- pytest_receptor-1.0.0.dist-info/RECORD +11 -0
- pytest_receptor-1.0.0.dist-info/WHEEL +5 -0
- pytest_receptor-1.0.0.dist-info/entry_points.txt +2 -0
- pytest_receptor-1.0.0.dist-info/licenses/LICENSE +21 -0
- pytest_receptor-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
2
|
+
|
|
3
|
+
try:
|
|
4
|
+
# The version of an installed package, from its distribution metadata.
|
|
5
|
+
__version__ = version("pytest-receptor")
|
|
6
|
+
except PackageNotFoundError:
|
|
7
|
+
try:
|
|
8
|
+
# A source tree that has been built at least once: versioningit wrote it.
|
|
9
|
+
from ._version import __version__
|
|
10
|
+
except ImportError:
|
|
11
|
+
# A fresh, unbuilt checkout with no VCS metadata.
|
|
12
|
+
__version__ = "0.0.0+unknown"
|
|
13
|
+
|
|
14
|
+
from .artifact import ( # noqa: E402
|
|
15
|
+
Artifact,
|
|
16
|
+
ArtifactError,
|
|
17
|
+
ArtifactFormatError,
|
|
18
|
+
ArtifactIntegrityError,
|
|
19
|
+
ArtifactRecord,
|
|
20
|
+
UnsupportedSchemaError,
|
|
21
|
+
read_artifact,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"Artifact",
|
|
26
|
+
"ArtifactError",
|
|
27
|
+
"ArtifactFormatError",
|
|
28
|
+
"ArtifactIntegrityError",
|
|
29
|
+
"ArtifactRecord",
|
|
30
|
+
"UnsupportedSchemaError",
|
|
31
|
+
"__version__",
|
|
32
|
+
"read_artifact",
|
|
33
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.0"
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""Versioned JSONL evidence artifacts and their supported reader API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
from dataclasses import asdict, dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Mapping
|
|
12
|
+
|
|
13
|
+
from .model import PhaseEvent, WarningEvent
|
|
14
|
+
|
|
15
|
+
SCHEMA = "pytest-receptor.events@1"
|
|
16
|
+
_SCHEMA_PATTERN = re.compile(r"^pytest-receptor\.events@(\d+)$")
|
|
17
|
+
_SUPPORTED_MAJOR = 1
|
|
18
|
+
_KNOWN_TYPES = {
|
|
19
|
+
"session_start",
|
|
20
|
+
"phase",
|
|
21
|
+
"warning",
|
|
22
|
+
"root_cause",
|
|
23
|
+
"evidence_limit",
|
|
24
|
+
"session_finish",
|
|
25
|
+
}
|
|
26
|
+
_NOFOLLOW = getattr(os, "O_NOFOLLOW", 0)
|
|
27
|
+
DEFAULT_MAX_BYTES = 50 * 1024 * 1024
|
|
28
|
+
MIN_MAX_BYTES = 64 * 1024
|
|
29
|
+
_FINAL_RESERVE = 32 * 1024
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ArtifactError(Exception):
|
|
33
|
+
"""Base class for artifact read/write failures."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class UnsupportedSchemaError(ArtifactError):
|
|
37
|
+
"""The artifact uses a schema major this reader cannot interpret."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ArtifactFormatError(ArtifactError):
|
|
41
|
+
"""The artifact is malformed rather than merely unfinished."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ArtifactIntegrityError(ArtifactError):
|
|
45
|
+
"""A finalized artifact does not match its integrity metadata."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class ArtifactRecord:
|
|
50
|
+
"""One record, including unknown types without destructive coercion."""
|
|
51
|
+
|
|
52
|
+
type: str
|
|
53
|
+
data: Mapping[str, Any]
|
|
54
|
+
known: bool
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class Artifact:
|
|
59
|
+
schema: str
|
|
60
|
+
records: tuple[ArtifactRecord, ...]
|
|
61
|
+
complete: bool
|
|
62
|
+
integrity_valid: bool | None
|
|
63
|
+
issue: str = ""
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def events(self) -> tuple[ArtifactRecord, ...]:
|
|
67
|
+
return tuple(
|
|
68
|
+
record
|
|
69
|
+
for record in self.records
|
|
70
|
+
if record.type not in ("session_start", "session_finish")
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def final(self) -> ArtifactRecord | None:
|
|
75
|
+
if self.records and self.records[-1].type == "session_finish":
|
|
76
|
+
return self.records[-1]
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class JsonlArtifactWriter:
|
|
81
|
+
"""Append normalized events and make completion cryptographically evident."""
|
|
82
|
+
|
|
83
|
+
def __init__(
|
|
84
|
+
self,
|
|
85
|
+
path: Path,
|
|
86
|
+
start_record: Mapping[str, Any],
|
|
87
|
+
*,
|
|
88
|
+
max_bytes: int = DEFAULT_MAX_BYTES,
|
|
89
|
+
):
|
|
90
|
+
if max_bytes < MIN_MAX_BYTES:
|
|
91
|
+
raise ArtifactError(f"artifact max bytes must be at least {MIN_MAX_BYTES}")
|
|
92
|
+
self.path = path
|
|
93
|
+
self.max_bytes = max_bytes
|
|
94
|
+
self._digest = hashlib.sha256()
|
|
95
|
+
self._record_count = 0
|
|
96
|
+
self._bytes_written = 0
|
|
97
|
+
self._truncated = False
|
|
98
|
+
self._dropped_records = 0
|
|
99
|
+
self._closed = False
|
|
100
|
+
self._schema = str(start_record.get("schema", SCHEMA))
|
|
101
|
+
self._run_id = str(start_record.get("run_id", ""))
|
|
102
|
+
if path.is_symlink():
|
|
103
|
+
raise ArtifactError(f"refusing symlink: {path}")
|
|
104
|
+
try:
|
|
105
|
+
descriptor = os.open(
|
|
106
|
+
path,
|
|
107
|
+
os.O_WRONLY | os.O_CREAT | os.O_TRUNC | _NOFOLLOW,
|
|
108
|
+
0o600,
|
|
109
|
+
)
|
|
110
|
+
self._handle = os.fdopen(descriptor, "wb")
|
|
111
|
+
os.chmod(path, 0o600)
|
|
112
|
+
except OSError as exc:
|
|
113
|
+
raise ArtifactError(str(exc)) from exc
|
|
114
|
+
self.write(start_record)
|
|
115
|
+
|
|
116
|
+
def write(self, record: Mapping[str, Any]) -> bool:
|
|
117
|
+
if self._closed:
|
|
118
|
+
raise ArtifactError("artifact is closed")
|
|
119
|
+
if self._truncated:
|
|
120
|
+
self._dropped_records += 1
|
|
121
|
+
return False
|
|
122
|
+
payload = _encode_record(record)
|
|
123
|
+
if self._bytes_written + len(payload) + _FINAL_RESERVE > self.max_bytes:
|
|
124
|
+
self._truncated = True
|
|
125
|
+
self._dropped_records = 1
|
|
126
|
+
marker = _encode_record(
|
|
127
|
+
{
|
|
128
|
+
"schema": self._schema,
|
|
129
|
+
"type": "evidence_limit",
|
|
130
|
+
"run_id": self._run_id,
|
|
131
|
+
"reason": "max_bytes",
|
|
132
|
+
"max_bytes": self.max_bytes,
|
|
133
|
+
}
|
|
134
|
+
)
|
|
135
|
+
if self._bytes_written + len(marker) + _FINAL_RESERVE <= self.max_bytes:
|
|
136
|
+
self._append(marker)
|
|
137
|
+
return False
|
|
138
|
+
self._append(payload)
|
|
139
|
+
return True
|
|
140
|
+
|
|
141
|
+
def _append(self, payload: bytes) -> None:
|
|
142
|
+
try:
|
|
143
|
+
self._handle.write(payload)
|
|
144
|
+
self._handle.flush()
|
|
145
|
+
except OSError as exc:
|
|
146
|
+
self.close()
|
|
147
|
+
raise ArtifactError(str(exc)) from exc
|
|
148
|
+
self._digest.update(payload)
|
|
149
|
+
self._record_count += 1
|
|
150
|
+
self._bytes_written += len(payload)
|
|
151
|
+
|
|
152
|
+
def write_phase(self, run_id: str, event: PhaseEvent) -> None:
|
|
153
|
+
record = asdict(event)
|
|
154
|
+
record.update(
|
|
155
|
+
{
|
|
156
|
+
"schema": SCHEMA,
|
|
157
|
+
"type": "phase",
|
|
158
|
+
"event_id": f"{run_id}:{event.sequence}",
|
|
159
|
+
}
|
|
160
|
+
)
|
|
161
|
+
self.write(record)
|
|
162
|
+
|
|
163
|
+
def write_warning(self, run_id: str, event: WarningEvent) -> None:
|
|
164
|
+
record = asdict(event)
|
|
165
|
+
record.update(
|
|
166
|
+
{
|
|
167
|
+
"schema": SCHEMA,
|
|
168
|
+
"type": "warning",
|
|
169
|
+
"event_id": f"{run_id}:{event.sequence}",
|
|
170
|
+
}
|
|
171
|
+
)
|
|
172
|
+
self.write(record)
|
|
173
|
+
|
|
174
|
+
def finalize(self, record: Mapping[str, Any]) -> None:
|
|
175
|
+
if self._closed:
|
|
176
|
+
raise ArtifactError("artifact is closed")
|
|
177
|
+
final = dict(record)
|
|
178
|
+
final["artifact_policy"] = {
|
|
179
|
+
"max_bytes": self.max_bytes,
|
|
180
|
+
"truncated": self._truncated,
|
|
181
|
+
"dropped_records": self._dropped_records,
|
|
182
|
+
}
|
|
183
|
+
final["integrity"] = {
|
|
184
|
+
"algorithm": "sha256",
|
|
185
|
+
"records": self._record_count,
|
|
186
|
+
"digest": self._digest.hexdigest(),
|
|
187
|
+
}
|
|
188
|
+
payload = _encode_record(final)
|
|
189
|
+
if self._bytes_written + len(payload) > self.max_bytes:
|
|
190
|
+
raise ArtifactError("final record exceeds artifact max bytes")
|
|
191
|
+
try:
|
|
192
|
+
self._handle.write(payload)
|
|
193
|
+
self._handle.flush()
|
|
194
|
+
os.fsync(self._handle.fileno())
|
|
195
|
+
self._bytes_written += len(payload)
|
|
196
|
+
except OSError as exc:
|
|
197
|
+
raise ArtifactError(str(exc)) from exc
|
|
198
|
+
finally:
|
|
199
|
+
self.close()
|
|
200
|
+
|
|
201
|
+
def close(self) -> None:
|
|
202
|
+
if self._closed:
|
|
203
|
+
return
|
|
204
|
+
self._closed = True
|
|
205
|
+
try:
|
|
206
|
+
self._handle.close()
|
|
207
|
+
except OSError:
|
|
208
|
+
pass
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def read_artifact(path: str | os.PathLike[str]) -> Artifact:
|
|
212
|
+
"""Read and validate an events artifact without discarding unknown events."""
|
|
213
|
+
source = Path(path)
|
|
214
|
+
try:
|
|
215
|
+
lines = source.read_bytes().splitlines(keepends=True)
|
|
216
|
+
except OSError as exc:
|
|
217
|
+
raise ArtifactError(str(exc)) from exc
|
|
218
|
+
if not lines:
|
|
219
|
+
raise ArtifactFormatError("artifact is empty")
|
|
220
|
+
|
|
221
|
+
records = []
|
|
222
|
+
decoded = []
|
|
223
|
+
issue = ""
|
|
224
|
+
for index, line in enumerate(lines):
|
|
225
|
+
try:
|
|
226
|
+
value = json.loads(line)
|
|
227
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
228
|
+
if index == len(lines) - 1:
|
|
229
|
+
issue = "truncated final record"
|
|
230
|
+
break
|
|
231
|
+
raise ArtifactFormatError(f"invalid JSON on line {index + 1}") from exc
|
|
232
|
+
if not isinstance(value, dict):
|
|
233
|
+
raise ArtifactFormatError(f"line {index + 1} is not a JSON object")
|
|
234
|
+
decoded.append((line, value))
|
|
235
|
+
|
|
236
|
+
if not decoded:
|
|
237
|
+
raise ArtifactFormatError("artifact has no complete record")
|
|
238
|
+
schema = str(decoded[0][1].get("schema", ""))
|
|
239
|
+
_validate_schema(schema)
|
|
240
|
+
for index, (_line, value) in enumerate(decoded, start=1):
|
|
241
|
+
if value.get("schema") != schema:
|
|
242
|
+
raise ArtifactFormatError(f"schema mismatch on line {index}")
|
|
243
|
+
record_type = str(value.get("type", ""))
|
|
244
|
+
if not record_type:
|
|
245
|
+
raise ArtifactFormatError(f"missing record type on line {index}")
|
|
246
|
+
records.append(
|
|
247
|
+
ArtifactRecord(record_type, dict(value), record_type in _KNOWN_TYPES)
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
complete = records[-1].type == "session_finish" and not issue
|
|
251
|
+
if not complete:
|
|
252
|
+
return Artifact(
|
|
253
|
+
schema,
|
|
254
|
+
tuple(records),
|
|
255
|
+
complete=False,
|
|
256
|
+
integrity_valid=None,
|
|
257
|
+
issue=issue or "missing session_finish",
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
integrity = records[-1].data.get("integrity")
|
|
261
|
+
if not isinstance(integrity, dict) or integrity.get("algorithm") != "sha256":
|
|
262
|
+
raise ArtifactIntegrityError("missing or unsupported integrity metadata")
|
|
263
|
+
expected_records = len(decoded) - 1
|
|
264
|
+
if integrity.get("records") != expected_records:
|
|
265
|
+
raise ArtifactIntegrityError("record count mismatch")
|
|
266
|
+
digest = hashlib.sha256()
|
|
267
|
+
for line, _value in decoded[:-1]:
|
|
268
|
+
digest.update(line)
|
|
269
|
+
if integrity.get("digest") != digest.hexdigest():
|
|
270
|
+
raise ArtifactIntegrityError("digest mismatch")
|
|
271
|
+
return Artifact(schema, tuple(records), complete=True, integrity_valid=True)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _validate_schema(schema: str) -> None:
|
|
275
|
+
match = _SCHEMA_PATTERN.fullmatch(schema)
|
|
276
|
+
if match is None:
|
|
277
|
+
raise ArtifactFormatError(f"invalid schema: {schema or '(missing)'}")
|
|
278
|
+
major = int(match.group(1))
|
|
279
|
+
if major != _SUPPORTED_MAJOR:
|
|
280
|
+
raise UnsupportedSchemaError(
|
|
281
|
+
f"unsupported schema major {major}; supported: {_SUPPORTED_MAJOR}"
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _encode_record(record: Mapping[str, Any]) -> bytes:
|
|
286
|
+
try:
|
|
287
|
+
text = json.dumps(
|
|
288
|
+
record,
|
|
289
|
+
ensure_ascii=False,
|
|
290
|
+
separators=(",", ":"),
|
|
291
|
+
sort_keys=True,
|
|
292
|
+
)
|
|
293
|
+
except (TypeError, ValueError) as exc:
|
|
294
|
+
raise ArtifactError(f"record is not JSON serializable: {exc}") from exc
|
|
295
|
+
return (text + "\n").encode("utf-8")
|
pytest_receptor/model.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Normalized, renderer-independent evidence for one pytest run.
|
|
2
|
+
|
|
3
|
+
This is an internal model while the ``pytest-receptor.events@1`` contract is
|
|
4
|
+
being proven. It deliberately contains only Python and JSON-friendly values:
|
|
5
|
+
pytest report objects must not cross this boundary.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class CapturedSection:
|
|
15
|
+
"""One captured section, preserving pytest's original name and contents."""
|
|
16
|
+
|
|
17
|
+
name: str
|
|
18
|
+
kind: str
|
|
19
|
+
content: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class ExceptionEvidence:
|
|
24
|
+
"""Exception evidence and how its type was obtained."""
|
|
25
|
+
|
|
26
|
+
type_name: str
|
|
27
|
+
qualified_type: str
|
|
28
|
+
type_source: str
|
|
29
|
+
message: str
|
|
30
|
+
location: str
|
|
31
|
+
cause: str = ""
|
|
32
|
+
frames: tuple[str, ...] = ()
|
|
33
|
+
raw_longrepr: str = ""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class SubtestIdentity:
|
|
38
|
+
"""One subtest within a logical pytest item and execution attempt."""
|
|
39
|
+
|
|
40
|
+
index: int
|
|
41
|
+
description: str
|
|
42
|
+
message: str = ""
|
|
43
|
+
parameters: tuple[tuple[str, str], ...] = ()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class PhaseEvent:
|
|
48
|
+
"""The outcome of one setup/call/teardown or collection phase."""
|
|
49
|
+
|
|
50
|
+
sequence: int
|
|
51
|
+
nodeid: str
|
|
52
|
+
phase: str
|
|
53
|
+
outcome: str
|
|
54
|
+
duration: float | None = None
|
|
55
|
+
worker_id: str = ""
|
|
56
|
+
attempt: int = 1
|
|
57
|
+
reason: str = ""
|
|
58
|
+
exception: ExceptionEvidence | None = None
|
|
59
|
+
sections: tuple[CapturedSection, ...] = ()
|
|
60
|
+
subtest: SubtestIdentity | None = None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class WarningEvent:
|
|
65
|
+
category: str
|
|
66
|
+
message: str
|
|
67
|
+
origin: str
|
|
68
|
+
nodeid: str = ""
|
|
69
|
+
when: str = ""
|
|
70
|
+
sequence: int = 0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class SessionEvidence:
|
|
75
|
+
"""Single mutable collection target for one session's normalized evidence."""
|
|
76
|
+
|
|
77
|
+
schema: str = "pytest-receptor.events@1"
|
|
78
|
+
phases: list[PhaseEvent] = field(default_factory=list)
|
|
79
|
+
warnings: list[WarningEvent] = field(default_factory=list)
|
|
80
|
+
outcomes: dict[str, str] = field(default_factory=dict)
|
|
81
|
+
errors: set[str] = field(default_factory=set)
|
|
82
|
+
skipped: dict[str, str] = field(default_factory=dict)
|
|
83
|
+
xfailed: dict[str, str] = field(default_factory=dict)
|
|
84
|
+
xpassed: list[tuple[str, str]] = field(default_factory=list)
|
|
85
|
+
collected: int = 0
|
|
86
|
+
deselected: int = 0
|
|
87
|
+
finished: int = 0
|
|
88
|
+
_sequence: int = 0
|
|
89
|
+
_current_attempt: dict[str, int] = field(default_factory=dict)
|
|
90
|
+
_subtest_indexes: dict[tuple[str, int], int] = field(default_factory=dict)
|
|
91
|
+
|
|
92
|
+
def next_sequence(self) -> int:
|
|
93
|
+
self._sequence += 1
|
|
94
|
+
return self._sequence
|
|
95
|
+
|
|
96
|
+
def attempt_for(self, nodeid: str, phase: str) -> int:
|
|
97
|
+
"""Return the execution attempt, not the number of call-like reports."""
|
|
98
|
+
if phase == "setup":
|
|
99
|
+
self._current_attempt[nodeid] = self._current_attempt.get(nodeid, 0) + 1
|
|
100
|
+
return self._current_attempt.get(nodeid, 1)
|
|
101
|
+
|
|
102
|
+
def next_subtest_index(self, nodeid: str, attempt: int) -> int:
|
|
103
|
+
key = (nodeid, attempt)
|
|
104
|
+
index = self._subtest_indexes.get(key, 0) + 1
|
|
105
|
+
self._subtest_indexes[key] = index
|
|
106
|
+
return index
|
|
107
|
+
|
|
108
|
+
def add_phase(self, event: PhaseEvent) -> None:
|
|
109
|
+
self.phases.append(event)
|
|
110
|
+
nodeid = event.nodeid
|
|
111
|
+
if event.phase == "teardown":
|
|
112
|
+
self.finished += 1
|
|
113
|
+
|
|
114
|
+
# Subtests are occurrences inside one collected logical item. A failed
|
|
115
|
+
# subtest fails that item, but a pass/skip/xfail must not turn the parent
|
|
116
|
+
# into a separately passed/skipped item; its ordinary call report owns
|
|
117
|
+
# the final logical outcome.
|
|
118
|
+
if event.subtest is not None:
|
|
119
|
+
if event.outcome == "failed":
|
|
120
|
+
self.outcomes[nodeid] = "failed"
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
if event.outcome == "failed":
|
|
124
|
+
if event.phase in ("setup", "teardown"):
|
|
125
|
+
self.errors.add(nodeid)
|
|
126
|
+
else:
|
|
127
|
+
self.outcomes[nodeid] = "failed"
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
if event.phase == "call":
|
|
131
|
+
if event.outcome == "xpassed":
|
|
132
|
+
self.xpassed.append((nodeid, event.reason))
|
|
133
|
+
self.outcomes[nodeid] = "xpassed"
|
|
134
|
+
elif event.outcome == "xfailed":
|
|
135
|
+
self.xfailed[nodeid] = event.reason
|
|
136
|
+
self.outcomes[nodeid] = "xfailed"
|
|
137
|
+
elif event.outcome == "skipped":
|
|
138
|
+
self.skipped[nodeid] = event.reason
|
|
139
|
+
self.outcomes[nodeid] = "skipped"
|
|
140
|
+
elif event.outcome == "passed":
|
|
141
|
+
self.outcomes.setdefault(nodeid, "passed")
|
|
142
|
+
elif event.phase == "setup" and event.outcome == "skipped":
|
|
143
|
+
self.skipped[nodeid] = event.reason
|
|
144
|
+
self.outcomes.setdefault(nodeid, "skipped")
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def failures(self) -> tuple[PhaseEvent, ...]:
|
|
148
|
+
"""Failures that determine the terminal diagnosis.
|
|
149
|
+
|
|
150
|
+
A rerun can carry exception evidence from a transient failed attempt,
|
|
151
|
+
but it must not be rendered as a final failure after a later pass.
|
|
152
|
+
"""
|
|
153
|
+
return tuple(
|
|
154
|
+
event
|
|
155
|
+
for event in self.phases
|
|
156
|
+
if event.exception is not None and event.outcome == "failed"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def exception_events(self) -> tuple[PhaseEvent, ...]:
|
|
161
|
+
"""All exception evidence, including transient rerun attempts."""
|
|
162
|
+
return tuple(event for event in self.phases if event.exception is not None)
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def executed(self) -> int:
|
|
166
|
+
return len(set(self.outcomes) | self.errors)
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def warning_count(self) -> int:
|
|
170
|
+
return len(self.warnings)
|
|
171
|
+
|
|
172
|
+
@property
|
|
173
|
+
def reruns(self) -> int:
|
|
174
|
+
return sum(event.outcome == "rerun" for event in self.phases)
|
|
175
|
+
|
|
176
|
+
@property
|
|
177
|
+
def subtest_counts(self) -> dict[str, int]:
|
|
178
|
+
counts: dict[str, int] = {}
|
|
179
|
+
for event in self.phases:
|
|
180
|
+
if event.subtest is not None:
|
|
181
|
+
counts[event.outcome] = counts.get(event.outcome, 0) + 1
|
|
182
|
+
return counts
|