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

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 

7 

8logger = logging.getLogger(__name__) 

9 

10 

11class TestOutcome(Enum): 

12 """Test outcome states. 

13 

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 """ 

23 

24 __test__ = False # Tell Pytest this is NOT a test class 

25 

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" 

33 

34 @classmethod 

35 def from_str(cls, outcome: Optional[str]) -> "TestOutcome": 

36 """Convert string to TestOutcome, always uppercase internally. 

37 

38 Args: 

39 outcome (Optional[str]): Outcome string. 

40 

41 Returns: 

42 TestOutcome: Corresponding enum value. 

43 

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}") 

51 

52 def to_str(self) -> str: 

53 """Convert TestOutcome to string, always lowercase externally. 

54 

55 Returns: 

56 str: Lowercase outcome string. 

57 

58 """ 

59 return self.value.lower() 

60 

61 @classmethod 

62 def to_list(cls) -> List[str]: 

63 """Convert entire TestOutcome enum to a list of possible string values. 

64 

65 Returns: 

66 List[str]: List of lowercase outcome strings. 

67 

68 """ 

69 return [outcome.value.lower() for outcome in cls] 

70 

71 def is_failed(self) -> bool: 

72 """Check if the outcome represents a failure. 

73 

74 Returns: 

75 bool: True if outcome is failure or error, else False. 

76 

77 """ 

78 return self in (self.FAILED, self.ERROR) 

79 

80 

81@dataclass 

82class TestResult: 

83 """Represents a single test result for an individual test run. 

84 

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. 

95 

96 """ 

97 

98 __test__ = False # Tell Pytest this is NOT a test class 

99 

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 

111 

112 def __post_init__(self): 

113 """Validate and process initialization data. 

114 

115 Raises: 

116 ValueError: If neither stop_time nor duration is provided. 

117 

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() 

125 

126 def to_dict(self) -> Dict: 

127 """Convert test result to a dictionary for JSON serialization. 

128 

129 Returns: 

130 dict: Dictionary representation of the test result. 

131 

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() 

145 

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 } 

157 

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) 

164 

165 stop_time = data.get("stop_time") 

166 if isinstance(stop_time, str): 

167 stop_time = datetime.fromisoformat(stop_time) 

168 

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 ) 

180 

181 

182@dataclass 

183class RerunTestGroup: 

184 """Groups test results for tests that were rerun, chronologically ordered with final result last. 

185 

186 Attributes: 

187 nodeid (str): Test node ID. 

188 tests (List[TestResult]): List of TestResult objects for each rerun. 

189 

190 """ 

191 

192 __test__ = False 

193 

194 nodeid: str 

195 tests: List[TestResult] = field(default_factory=list) 

196 

197 def add_test(self, result: "TestResult"): 

198 """Add a test result and maintain chronological order. 

199 

200 Args: 

201 result (TestResult): TestResult to add. 

202 

203 """ 

204 self.tests.append(result) 

205 self.tests.sort(key=lambda t: t.start_time) 

206 

207 @property 

208 def final_outcome(self): 

209 """Get the outcome of the final test (non-RERUN and non-ERROR). 

210 

211 Returns: 

212 Optional[TestOutcome]: Final outcome if available. 

213 

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 

219 

220 def to_dict(self) -> Dict: 

221 """Convert to dictionary for JSON serialization. 

222 

223 Returns: 

224 dict: Dictionary representation of the rerun group. 

225 

226 """ 

227 return {"nodeid": self.nodeid, "tests": [t.to_dict() for t in self.tests]} 

228 

229 @classmethod 

230 def from_dict(cls, data: Dict) -> "RerunTestGroup": 

231 """Create RerunTestGroup from dictionary. 

232 

233 Args: 

234 data (Dict): Dictionary representation of the rerun group. 

235 

236 Returns: 

237 RerunTestGroup: Instantiated RerunTestGroup object. 

238 

239 """ 

240 if not isinstance(data, dict): 

241 raise ValueError(f"Invalid data for RerunTestGroup. Expected dict, got {type(data)}") 

242 

243 group = cls(nodeid=data["nodeid"]) 

244 

245 tests = [TestResult.from_dict(test_dict) for test_dict in data.get("tests", [])] 

246 group.tests = tests 

247 return group 

248 

249 

250class TestSessionStats: 

251 """Aggregates session-level statistics, including test outcomes and other events (e.g., warnings). 

252 

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 """ 

263 

264 __test__ = False # Tell Pytest this is NOT a test class 

265 

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 

279 

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) 

283 

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()} 

287 

288 def __str__(self) -> str: 

289 """Return a string representation of the TestSessionStats object.""" 

290 return f"TestSessionStats(total={self.total}, {dict(self.counter)})" 

291 

292 

293@dataclass 

294class TestSession: 

295 """Represents a test session recap with session-level metadata, results. 

296 

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. 

307 

308 """ 

309 

310 __test__ = False # Tell Pytest this is NOT a test class 

311 

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)) 

337 

338 def to_dict(self) -> Dict: 

339 """Convert TestSession to a dictionary for JSON serialization. 

340 

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 } 

360 

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 ) 

388 

389 def add_test_result(self, result: TestResult) -> None: 

390 """Add a test result to this session. 

391 

392 Args: 

393 result (TestResult): TestResult to add. 

394 

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 ) 

402 

403 self.test_results.append(result) 

404 

405 def add_rerun_group(self, group: RerunTestGroup) -> None: 

406 """Add a rerun test group to this session. 

407 

408 Args: 

409 group (RerunTestGroup): RerunTestGroup to add. 

410 

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 ) 

418 

419 self.rerun_test_groups.append(group) 

420 

421 

422class RecapEventType(str, Enum): 

423 ERROR = "error" 

424 WARNING = "warning" 

425 

426 

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 

441 

442 def to_dict(self) -> dict: 

443 """Convert the RecapEvent to a dictionary.""" 

444 return asdict(self) 

445 

446 def is_warning(self) -> bool: 

447 """Return True if this event is classified as a warning.""" 

448 return self.event_type == RecapEventType.WARNING 

449 

450 def is_error(self) -> bool: 

451 """Return True if this event is classified as an error.""" 

452 return self.event_type == RecapEventType.ERROR