Coverage for pytest_recap/models.py: 0%
163 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-05-29 15:22 -0500
« prev ^ index » next coverage.py v7.8.0, created at 2025-05-29 15:22 -0500
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.
190 """
192 __test__ = False
194 nodeid: str
195 tests: List[TestResult] = field(default_factory=list)
197 def add_test(self, result: "TestResult"):
198 """Add a test result and maintain chronological order.
200 Args:
201 result (TestResult): TestResult to add.
203 """
204 self.tests.append(result)
205 self.tests.sort(key=lambda t: t.start_time)
207 @property
208 def final_outcome(self):
209 """Get the outcome of the final test (non-RERUN and non-ERROR).
211 Returns:
212 Optional[TestOutcome]: Final outcome if available.
214 """
215 outcomes = [t.outcome for t in self.tests]
216 if TestOutcome.FAILED in outcomes:
217 return TestOutcome.FAILED
218 return outcomes[-1] if outcomes else None
220 def to_dict(self) -> Dict:
221 """Convert to dictionary for JSON serialization.
223 Returns:
224 dict: Dictionary representation of the rerun group.
226 """
227 return {"nodeid": self.nodeid, "tests": [t.to_dict() for t in self.tests]}
229 @classmethod
230 def from_dict(cls, data: Dict) -> "RerunTestGroup":
231 """Create RerunTestGroup from dictionary.
233 Args:
234 data (Dict): Dictionary representation of the rerun group.
236 Returns:
237 RerunTestGroup: Instantiated RerunTestGroup object.
239 """
240 if not isinstance(data, dict):
241 raise ValueError(f"Invalid data for RerunTestGroup. Expected dict, got {type(data)}")
243 group = cls(nodeid=data["nodeid"])
245 tests = [TestResult.from_dict(test_dict) for test_dict in data.get("tests", [])]
246 group.tests = tests
247 return group
250class TestSessionStats:
251 """Aggregates session-level statistics, including test outcomes and other events (e.g., warnings).
253 Attributes:
254 passed (int): Number of passed tests
255 failed (int): Number of failed tests
256 skipped (int): Number of skipped tests
257 xfailed (int): Number of unexpectedly failed tests
258 xpassed (int): Number of unexpectedly passed tests
259 error (int): Number of error tests
260 rerun (int): Number of rerun tests
261 warnings (int): Number of warnings encountered in this session
262 """
264 __test__ = False # Tell Pytest this is NOT a test class
266 def __init__(self, test_results: Iterable[Any], warnings_count: int = 0):
267 """
268 Args:
269 test_results (Iterable[TestResult]): List of TestResult objects.
270 warning_count (int): Number of warnings in the session.
271 """
272 # Aggregate test outcomes (e.g., passed, failed, etc.)
273 self.counter = Counter(
274 str(getattr(test_result, "outcome", test_result)).lower() for test_result in test_results
275 )
276 self.total = len(test_results)
277 # Add warnings as a separate count
278 self.counter["warnings"] = warnings_count
280 def count(self, key: str) -> int:
281 """Return the count for a given outcome or event (case-insensitive string)."""
282 return self.counter.get(key.lower(), 0)
284 def as_dict(self) -> Dict[str, int]:
285 """Return all session-level event counts as a dict, with 'testoutcome.' prefix removed from keys."""
286 return {k[len("testoutcome.") :] if k.startswith("testoutcome.") else k: v for k, v in self.counter.items()}
288 def __str__(self) -> str:
289 """Return a string representation of the TestSessionStats object."""
290 return f"TestSessionStats(total={self.total}, {dict(self.counter)})"
293@dataclass
294class TestSession:
295 """Represents a test session recap with session-level metadata, results.
297 Attributes:
298 session_id (str): Unique session identifier.
299 session_start_time (datetime): Start time of the session.
300 session_stop_time (datetime): Stop time of the session.
301 system_under_test (dict): Information about the system under test (user-extensible).
302 session_tags (Dict[str, str]): Arbitrary tags for the session.
303 testing_system (Dict[str, Any]): Metadata about the testing system.
304 test_results (List[TestResult]): List of test results in the session.
305 rerun_test_groups (List[RerunTestGroup]): Groups of rerun tests.
306 session_stats (TestSessionStats): Test session statistics.
308 """
310 __test__ = False # Tell Pytest this is NOT a test class
312 def __init__(
313 self,
314 session_id: str,
315 session_start_time: datetime,
316 session_stop_time: datetime = None,
317 system_under_test: dict = None,
318 session_tags: dict = None,
319 testing_system: dict = None,
320 test_results: list = None,
321 rerun_test_groups: list = None,
322 warnings: Optional[List["RecapEvent"]] = None,
323 errors: Optional[List["RecapEvent"]] = None,
324 session_stats: TestSessionStats = None,
325 ):
326 self.session_id = session_id
327 self.session_start_time = session_start_time
328 self.session_stop_time = session_stop_time or datetime.now(timezone.utc)
329 self.system_under_test = system_under_test or {}
330 self.session_tags = session_tags or {}
331 self.testing_system = testing_system or {}
332 self.test_results = test_results or []
333 self.rerun_test_groups = rerun_test_groups or []
334 self.warnings = warnings or []
335 self.errors = errors or []
336 self.session_stats = session_stats or TestSessionStats(self.test_results, len(self.warnings))
338 def to_dict(self) -> Dict:
339 """Convert TestSession to a dictionary for JSON serialization.
341 Returns:
342 dict: Dictionary representation of the test session.
343 """
344 return {
345 "session_id": self.session_id,
346 "session_tags": self.session_tags or {},
347 "session_start_time": self.session_start_time.isoformat(),
348 "session_stop_time": self.session_stop_time.isoformat(),
349 "system_under_test": self.system_under_test or {},
350 "testing_system": self.testing_system or {},
351 "test_results": [test.to_dict() for test in self.test_results],
352 "rerun_test_groups": [
353 {"nodeid": group.nodeid, "tests": [t.to_dict() for t in group.tests]}
354 for group in self.rerun_test_groups
355 ],
356 "warnings": [w.to_dict() for w in self.warnings],
357 "errors": [e.to_dict() for e in self.errors],
358 "session_stats": self.session_stats.as_dict() if self.session_stats else {},
359 }
361 @classmethod
362 def from_dict(cls, d):
363 """Create a TestSession from a dictionary. Ensures warnings count is passed to TestSessionStats."""
364 if not isinstance(d, dict):
365 raise ValueError(f"Invalid data for TestSession. Expected dict, got {type(d)}")
366 session_start_time = d.get("session_start_time")
367 if isinstance(session_start_time, str):
368 session_start_time = datetime.fromisoformat(session_start_time)
369 session_stop_time = d.get("session_stop_time")
370 if isinstance(session_stop_time, str):
371 session_stop_time = datetime.fromisoformat(session_stop_time)
372 test_results = [TestResult.from_dict(test_result) for test_result in d.get("test_results", [])]
373 warnings = [RecapEvent(**w) if not isinstance(w, RecapEvent) else w for w in d.get("warnings", [])]
374 session_stats = TestSessionStats(test_results, warnings_count=len(warnings))
375 return cls(
376 session_id=d.get("session_id"),
377 session_start_time=session_start_time,
378 session_stop_time=session_stop_time,
379 system_under_test=d.get("system_under_test", {}),
380 session_tags=d.get("session_tags", {}),
381 testing_system=d.get("testing_system", {}),
382 test_results=test_results,
383 rerun_test_groups=[RerunTestGroup.from_dict(g) for g in d.get("rerun_test_groups", [])],
384 warnings=warnings,
385 errors=d.get("errors", []),
386 session_stats=session_stats,
387 )
389 def add_test_result(self, result: TestResult) -> None:
390 """Add a test result to this session.
392 Args:
393 result (TestResult): TestResult to add.
395 Raises:
396 ValueError: If result is not a TestResult instance.
397 """
398 if not isinstance(result, TestResult):
399 raise ValueError(
400 f"Invalid test result {result}; must be a TestResult object, nistead was type {type(result)}"
401 )
403 self.test_results.append(result)
405 def add_rerun_group(self, group: RerunTestGroup) -> None:
406 """Add a rerun test group to this session.
408 Args:
409 group (RerunTestGroup): RerunTestGroup to add.
411 Raises:
412 ValueError: If group is not a RerunTestGroup instance.
413 """
414 if not isinstance(group, RerunTestGroup):
415 raise ValueError(
416 f"Invalid rerun group {group}; must be a RerunTestGroup object, instead was type {type(group)}"
417 )
419 self.rerun_test_groups.append(group)
422class RecapEventType(str, Enum):
423 ERROR = "error"
424 WARNING = "warning"
427@dataclass
428class RecapEvent:
429 event_type: RecapEventType = RecapEventType.WARNING
430 nodeid: Optional[str] = None
431 when: Optional[str] = None
432 outcome: Optional[str] = None
433 message: Optional[str] = None
434 category: Optional[str] = None
435 filename: Optional[str] = None
436 lineno: Optional[int] = None
437 longrepr: Optional[Any] = None
438 sections: List[Any] = field(default_factory=list)
439 keywords: List[str] = field(default_factory=list)
440 location: Optional[Any] = None
442 def to_dict(self) -> dict:
443 """Convert the RecapEvent to a dictionary."""
444 return asdict(self)
446 def is_warning(self) -> bool:
447 """Return True if this event is classified as a warning."""
448 return self.event_type == RecapEventType.WARNING
450 def is_error(self) -> bool:
451 """Return True if this event is classified as an error."""
452 return self.event_type == RecapEventType.ERROR