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