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

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 __test__ = False 

192 nodeid: str 

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

194 

195 @property 

196 def final_outcome(self) -> Optional[str]: 

197 """Compute the final outcome for the group based on test results. 

198 

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 

204 

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" 

208 

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 

214 

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) 

219 

220 def to_dict(self) -> Dict: 

221 d = {"nodeid": self.nodeid, "tests": [t.to_dict() for t in self.tests]} 

222 return d 

223 

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 

233 

234 

235class TestSessionStats: 

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

237 

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

248 

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

250 

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 

264 

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) 

268 

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 

280 

281 def __str__(self) -> str: 

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

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

284 

285 

286@dataclass 

287class TestSession: 

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

289 

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. 

300 

301 """ 

302 

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

304 

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

330 

331 def to_dict(self) -> Dict: 

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

333 

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 } 

353 

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 ) 

387 

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

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

390 

391 Args: 

392 result (TestResult): TestResult to add. 

393 

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 ) 

401 

402 self.test_results.append(result) 

403 

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

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

406 

407 Args: 

408 group (RerunTestGroup): RerunTestGroup to add. 

409 

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 ) 

417 

418 self.rerun_test_groups.append(group) 

419 

420 

421class RecapEventType(str, Enum): 

422 ERROR = "error" 

423 WARNING = "warning" 

424 

425 

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 

440 

441 def to_dict(self) -> dict: 

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

443 return asdict(self) 

444 

445 def is_warning(self) -> bool: 

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

447 return self.event_type == RecapEventType.WARNING 

448 

449 def is_error(self) -> bool: 

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

451 return self.event_type == RecapEventType.ERROR