Coverage for pytest_recap/models.py: 49%
171 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-06-16 00:19 -0600
« prev ^ index » next coverage.py v7.8.0, created at 2025-06-16 00:19 -0600
1import logging
2from collections import Counter
3from dataclasses import asdict, dataclass, field
4from datetime import datetime, timedelta, timezone
5from enum import Enum
6from typing import Any, Dict, Iterable, List, Optional
8logger = logging.getLogger(__name__)
11class TestOutcome(Enum):
12 """Test outcome states.
14 Enum values:
15 PASSED: Test passed
16 FAILED: Test failed
17 SKIPPED: Test skipped
18 XFAILED: Expected failure
19 XPASSED: Unexpected pass
20 RERUN: Test was rerun
21 ERROR: Test errored
22 """
24 __test__ = False # Tell Pytest this is NOT a test class
26 PASSED = "PASSED" # Internal representation in UPPERCASE
27 FAILED = "FAILED"
28 SKIPPED = "SKIPPED"
29 XFAILED = "XFAILED"
30 XPASSED = "XPASSED"
31 RERUN = "RERUN"
32 ERROR = "ERROR"
34 @classmethod
35 def from_str(cls, outcome: Optional[str]) -> "TestOutcome":
36 """Convert string to TestOutcome, always uppercase internally.
38 Args:
39 outcome (Optional[str]): Outcome string.
41 Returns:
42 TestOutcome: Corresponding enum value.
44 """
45 if not outcome:
46 return cls.SKIPPED # Return a default enum value instead of None
47 try:
48 return cls[outcome.upper()]
49 except KeyError:
50 raise ValueError(f"Invalid test outcome: {outcome}")
52 def to_str(self) -> str:
53 """Convert TestOutcome to string, always lowercase externally.
55 Returns:
56 str: Lowercase outcome string.
58 """
59 return self.value.lower()
61 @classmethod
62 def to_list(cls) -> List[str]:
63 """Convert entire TestOutcome enum to a list of possible string values.
65 Returns:
66 List[str]: List of lowercase outcome strings.
68 """
69 return [outcome.value.lower() for outcome in cls]
71 def is_failed(self) -> bool:
72 """Check if the outcome represents a failure.
74 Returns:
75 bool: True if outcome is failure or error, else False.
77 """
78 return self in (self.FAILED, self.ERROR)
81@dataclass
82class TestResult:
83 """Represents a single test result for an individual test run.
85 Attributes:
86 nodeid (str): Unique identifier for the test node.
87 outcome (TestOutcome): Result outcome.
88 start_time (Optional[datetime]): Start time of the test.
89 stop_time (Optional[datetime]): Stop time of the test.
90 duration (Optional[float]): Duration in seconds.
91 caplog (str): Captured log output.
92 capstderr (str): Captured stderr output.
93 capstdout (str): Captured stdout output.
94 longreprtext (str): Long representation of failure, if any.
96 """
98 __test__ = False # Tell Pytest this is NOT a test class
100 nodeid: str
101 outcome: TestOutcome
102 start_time: Optional[datetime] = None
103 stop_time: Optional[datetime] = None
104 duration: Optional[float] = None
105 caplog: str = ""
106 capstderr: str = ""
107 capstdout: str = ""
108 longreprtext: str = ""
109 has_warning: bool = False
110 has_error: bool = False
112 def __post_init__(self):
113 """Validate and process initialization data.
115 Raises:
116 ValueError: If neither stop_time nor duration is provided.
118 """
119 # Only compute stop_time if both start_time and duration are present and stop_time is missing
120 if self.stop_time is None and self.start_time is not None and self.duration is not None:
121 self.stop_time = self.start_time + timedelta(seconds=self.duration)
122 # Only compute duration if both start_time and stop_time are present and duration is missing
123 elif self.duration is None and self.start_time is not None and self.stop_time is not None:
124 self.duration = (self.stop_time - self.start_time).total_seconds()
126 def to_dict(self) -> Dict:
127 """Convert test result to a dictionary for JSON serialization.
129 Returns:
130 dict: Dictionary representation of the test result.
132 """
133 # Handle both string and enum outcomes for backward compatibility
134 if not hasattr(self.outcome, "to_str"):
135 logger.warning(
136 "Non-enum (probably string outcome detected where TestOutcome enum expected. "
137 f"nodeid={self.nodeid}, outcome={self.outcome}, type={type(self.outcome)}. "
138 "For proper session context and query filtering, use TestOutcome enum: "
139 "outcome=TestOutcome.FAILED instead of outcome='failed'. "
140 "String outcomes are deprecated and will be removed in a future version."
141 )
142 outcome_str = str(self.outcome).lower()
143 else:
144 outcome_str = self.outcome.to_str()
146 return {
147 "nodeid": self.nodeid,
148 "outcome": outcome_str,
149 "start_time": self.start_time.isoformat() if self.start_time else None,
150 "stop_time": self.stop_time.isoformat() if self.stop_time else None,
151 "duration": self.duration,
152 "caplog": self.caplog,
153 "capstderr": self.capstderr,
154 "capstdout": self.capstdout,
155 "longreprtext": self.longreprtext,
156 }
158 @classmethod
159 def from_dict(cls, data: Dict) -> "TestResult":
160 """Create a TestResult from a dictionary."""
161 start_time = data.get("start_time")
162 if isinstance(start_time, str):
163 start_time = datetime.fromisoformat(start_time)
165 stop_time = data.get("stop_time")
166 if isinstance(stop_time, str):
167 stop_time = datetime.fromisoformat(stop_time)
169 return cls(
170 nodeid=data["nodeid"],
171 outcome=TestOutcome.from_str(data["outcome"]),
172 start_time=start_time,
173 stop_time=stop_time,
174 duration=data.get("duration"),
175 caplog=data.get("caplog", ""),
176 capstderr=data.get("capstderr", ""),
177 capstdout=data.get("capstdout", ""),
178 longreprtext=data.get("longreprtext", ""),
179 )
182@dataclass
183class RerunTestGroup:
184 """Groups test results for tests that were rerun, chronologically ordered with final result last.
186 Attributes:
187 nodeid (str): Test node ID.
188 tests (List[TestResult]): List of TestResult objects for each rerun.
189 """
191 __test__ = False
192 nodeid: str
193 tests: List["TestResult"] = field(default_factory=list)
195 @property
196 def final_outcome(self) -> Optional[str]:
197 """Compute the final outcome for the group based on test results.
199 Returns:
200 Optional[str]: The computed final outcome (e.g., "passed", "failed", "error"), or None if no tests.
201 """
202 if not self.tests:
203 return None
205 # Make sure only one test has an outcome that is not "rerun"
206 non_rerun_count = sum(test.outcome.value.lower() != "rerun" for test in self.tests)
207 assert non_rerun_count == 1, f"Expected at most one non-rerun test, got {non_rerun_count} instead"
209 # The final outcome is the outcome of the only test that is not a rerun
210 for test in self.tests:
211 if test.outcome.value.lower() != "rerun":
212 return test.outcome.value.lower()
213 return None
215 def add_test(self, result: "TestResult"):
216 """Add a test result and maintain chronological order."""
217 self.tests.append(result)
218 self.tests.sort(key=lambda t: t.start_time)
220 def to_dict(self) -> Dict:
221 d = {"nodeid": self.nodeid, "tests": [t.to_dict() for t in self.tests]}
222 return d
224 @classmethod
225 def from_dict(cls, data: Dict) -> "RerunTestGroup":
226 if not isinstance(data, dict):
227 raise ValueError(f"Invalid data for RerunTestGroup. Expected dict, got {type(data)}")
228 group = cls(
229 nodeid=data["nodeid"],
230 tests=[TestResult.from_dict(test_dict) for test_dict in data.get("tests", [])],
231 )
232 return group
235class TestSessionStats:
236 """Aggregates session-level statistics, including test outcomes and other events (e.g., warnings).
238 Attributes:
239 passed (int): Number of passed tests
240 failed (int): Number of failed tests
241 skipped (int): Number of skipped tests
242 xfailed (int): Number of unexpectedly failed tests
243 xpassed (int): Number of unexpectedly passed tests
244 error (int): Number of error tests
245 rerun (int): Number of rerun tests
246 warnings (int): Number of warnings encountered in this session
247 """
249 __test__ = False # Tell Pytest this is NOT a test class
251 def __init__(self, test_results: Iterable[Any], warnings_count: int = 0):
252 """
253 Args:
254 test_results (Iterable[TestResult]): List of TestResult objects.
255 warning_count (int): Number of warnings in the session.
256 """
257 # Aggregate test outcomes (e.g., passed, failed, etc.)
258 self.counter = Counter(
259 str(getattr(test_result, "outcome", test_result)).lower() for test_result in test_results
260 )
261 self.total = len(test_results)
262 # Add warnings as a separate count (always present, even if zero)
263 self.counter["warnings"] = warnings_count
265 def count(self, key: str) -> int:
266 """Return the count for a given outcome or event (case-insensitive string)."""
267 return self.counter.get(key.lower(), 0)
269 def as_dict(self) -> Dict[str, int]:
270 """Return all session-level event counts as a dict, with 'testoutcome.' prefix removed from keys. Always include 'warnings' if present in counter, even if zero."""
271 d = {
272 (k[len("testoutcome.") :] if k.startswith("testoutcome.") else k): v
273 for k, v in self.counter.items()
274 if v > 0
275 }
276 # Always include 'warnings' if present in counter, even if zero
277 if "warnings" in self.counter and "warnings" not in d:
278 d["warnings"] = 0
279 return d
281 def __str__(self) -> str:
282 """Return a string representation of the TestSessionStats object."""
283 return f"TestSessionStats(total={self.total}, {dict(self.counter)})"
286@dataclass
287class TestSession:
288 """Represents a test session recap with session-level metadata, results.
290 Attributes:
291 session_id (str): Unique session identifier.
292 session_start_time (datetime): Start time of the session.
293 session_stop_time (datetime): Stop time of the session.
294 system_under_test (dict): Information about the system under test (user-extensible).
295 session_tags (Dict[str, str]): Arbitrary tags for the session.
296 testing_system (Dict[str, Any]): Metadata about the testing system.
297 test_results (List[TestResult]): List of test results in the session.
298 rerun_test_groups (List[RerunTestGroup]): Groups of rerun tests.
299 session_stats (TestSessionStats): Test session statistics.
301 """
303 __test__ = False # Tell Pytest this is NOT a test class
305 def __init__(
306 self,
307 session_id: str,
308 session_start_time: datetime,
309 session_stop_time: datetime = None,
310 system_under_test: dict = None,
311 session_tags: dict = None,
312 testing_system: dict = None,
313 test_results: list = None,
314 rerun_test_groups: list = None,
315 warnings: Optional[List["RecapEvent"]] = None,
316 errors: Optional[List["RecapEvent"]] = None,
317 session_stats: TestSessionStats = None,
318 ):
319 self.session_id = session_id
320 self.session_start_time = session_start_time
321 self.session_stop_time = session_stop_time or datetime.now(timezone.utc)
322 self.system_under_test = system_under_test or {}
323 self.session_tags = session_tags or {}
324 self.testing_system = testing_system or {}
325 self.test_results = test_results or []
326 self.rerun_test_groups = rerun_test_groups or []
327 self.warnings = warnings or []
328 self.errors = errors or []
329 self.session_stats = session_stats or TestSessionStats(self.test_results, len(self.warnings))
331 def to_dict(self) -> Dict:
332 """Convert TestSession to a dictionary for JSON serialization.
334 Returns:
335 dict: Dictionary representation of the test session.
336 """
337 return {
338 "session_id": self.session_id,
339 "session_tags": self.session_tags or {},
340 "session_start_time": self.session_start_time.isoformat(),
341 "session_stop_time": self.session_stop_time.isoformat(),
342 "system_under_test": self.system_under_test or {},
343 "testing_system": self.testing_system or {},
344 "test_results": [test.to_dict() for test in self.test_results],
345 "rerun_test_groups": [
346 {"nodeid": group.nodeid, "tests": [t.to_dict() for t in group.tests]}
347 for group in self.rerun_test_groups
348 ],
349 "warnings": [w.to_dict() for w in self.warnings],
350 "errors": [e.to_dict() for e in self.errors],
351 "session_stats": self.session_stats.as_dict() if self.session_stats else {},
352 }
354 @classmethod
355 def from_dict(cls, d):
356 """Create a TestSession from a dictionary. Ensures all nested fields are model objects."""
357 if not isinstance(d, dict):
358 raise ValueError(f"Invalid data for TestSession. Expected dict, got {type(d)}")
359 session_start_time = d.get("session_start_time")
360 if isinstance(session_start_time, str):
361 session_start_time = datetime.fromisoformat(session_start_time)
362 session_stop_time = d.get("session_stop_time")
363 if isinstance(session_stop_time, str):
364 session_stop_time = datetime.fromisoformat(session_stop_time)
365 # Always convert nested fields
366 test_results = [TestResult.from_dict(tr) if isinstance(tr, dict) else tr for tr in d.get("test_results", [])]
367 rerun_test_groups = [
368 RerunTestGroup.from_dict(g) if isinstance(g, dict) else g for g in d.get("rerun_test_groups", [])
369 ]
370 warnings = [RecapEvent(**w) if isinstance(w, dict) else w for w in d.get("warnings", [])]
371 errors = [RecapEvent(**e) if isinstance(e, dict) else e for e in d.get("errors", [])]
372 # Always reconstruct session_stats from test_results and warnings
373 session_stats = TestSessionStats(test_results, warnings_count=len(warnings))
374 return cls(
375 session_id=d.get("session_id"),
376 session_start_time=session_start_time,
377 session_stop_time=session_stop_time,
378 system_under_test=d.get("system_under_test", {}),
379 session_tags=d.get("session_tags", {}),
380 testing_system=d.get("testing_system", {}),
381 test_results=test_results,
382 rerun_test_groups=rerun_test_groups,
383 warnings=warnings,
384 errors=errors,
385 session_stats=session_stats,
386 )
388 def add_test_result(self, result: TestResult) -> None:
389 """Add a test result to this session.
391 Args:
392 result (TestResult): TestResult to add.
394 Raises:
395 ValueError: If result is not a TestResult instance.
396 """
397 if not isinstance(result, TestResult):
398 raise ValueError(
399 f"Invalid test result {result}; must be a TestResult object, nistead was type {type(result)}"
400 )
402 self.test_results.append(result)
404 def add_rerun_group(self, group: RerunTestGroup) -> None:
405 """Add a rerun test group to this session.
407 Args:
408 group (RerunTestGroup): RerunTestGroup to add.
410 Raises:
411 ValueError: If group is not a RerunTestGroup instance.
412 """
413 if not isinstance(group, RerunTestGroup):
414 raise ValueError(
415 f"Invalid rerun group {group}; must be a RerunTestGroup object, instead was type {type(group)}"
416 )
418 self.rerun_test_groups.append(group)
421class RecapEventType(str, Enum):
422 ERROR = "error"
423 WARNING = "warning"
426@dataclass
427class RecapEvent:
428 event_type: RecapEventType = RecapEventType.WARNING
429 nodeid: Optional[str] = None
430 when: Optional[str] = None
431 outcome: Optional[str] = None
432 message: Optional[str] = None
433 category: Optional[str] = None
434 filename: Optional[str] = None
435 lineno: Optional[int] = None
436 longrepr: Optional[Any] = None
437 sections: List[Any] = field(default_factory=list)
438 keywords: List[str] = field(default_factory=list)
439 location: Optional[Any] = None
441 def to_dict(self) -> dict:
442 """Convert the RecapEvent to a dictionary."""
443 return asdict(self)
445 def is_warning(self) -> bool:
446 """Return True if this event is classified as a warning."""
447 return self.event_type == RecapEventType.WARNING
449 def is_error(self) -> bool:
450 """Return True if this event is classified as an error."""
451 return self.event_type == RecapEventType.ERROR