Coverage for src / ocarina / opinionated / plugins / reports / docx_tests_proofs.py: 86.06%

133 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-05-17 19:20 +0200

1"""Generate test proof documents in DOCX format. 

2 

3Transforms text log files from automated tests into formatted Word documents, 

4including screenshots. 

5 

6Architecture: 

7 - Recursive reading of log tree (campaigns > suites > cases) 

8 - UTC metadata parsing and local time conversion 

9 - Automatic screenshot detection and insertion 

10 - Robust error handling with logging 

11 

12Output format: 

13 - Heading 1: Test campaign 

14 - Heading 2: Test suite 

15 - Heading 3: Test case 

16 - Body: Logs with inserted screenshots 

17 

18Example: 

19 >>> generate_docx_proof( 

20 ... logs_root=Path("logs/e2e"), 

21 ... docx_root=Path(".docx_test_proofs_xxx"), 

22 ... logger=logger, 

23 ... ) 

24 

25""" 

26 

27import re 

28import uuid 

29from contextlib import suppress 

30from datetime import datetime 

31from pathlib import Path 

32from typing import TYPE_CHECKING, TypedDict, final 

33 

34from docx import Document 

35from docx.enum.text import WD_PARAGRAPH_ALIGNMENT 

36from docx.shared import Inches 

37 

38if TYPE_CHECKING: 

39 from collections.abc import Iterator 

40 

41 from docx.document import Document as DocxDocument 

42 

43 from ocarina.ports.ilogger import ILogger 

44 

45_DEFAULT_SCREENSHOT_NEEDLE = "Screenshot: " 

46_DEFAULT_UTC_DATE_REGEX = re.compile(r"\[UTC_DATE::([^]]+)]") 

47 

48 

49def _replace_utc_date(line: str, *, utc_date_regex: re.Pattern[str]) -> str: 

50 def _repl(m: re.Match[str]) -> str: 

51 with suppress(Exception): 

52 return ( 

53 datetime.fromisoformat(m.group(1).replace("Z", "+00:00")) # noqa: FURB162 

54 .astimezone() 

55 .strftime("[%m/%d/%Y | %Hh%M:%S.%f]") 

56 ) 

57 return m.group(0) 

58 

59 return utc_date_regex.sub(_repl, line) 

60 

61 

62def _shorten_docx_path(docx_path: Path) -> Path: 

63 """Return a short unique path if stem exceeds 8 chars, otherwise unchanged. 

64 

65 Raises: 

66 RuntimeError: If a unique filename cannot be generated after 500 attempts. 

67 

68 """ 

69 max_stem_length = 8 

70 retries = 500 

71 

72 if len(docx_path.stem) <= max_stem_length: 72 ↛ 73line 72 didn't jump to line 73 because the condition on line 72 was never true

73 return docx_path 

74 

75 parent = docx_path.parent 

76 for _ in range(retries): 

77 short_name = uuid.uuid4().hex[:max_stem_length] 

78 new_path = parent / f"{short_name}.docx" 

79 if not new_path.exists(): 79 ↛ 76line 79 didn't jump to line 76 because the condition on line 79 was always true

80 return new_path 

81 

82 msg = ( # pragma: no cover 

83 f"Cannot generate a unique short filename for:" 

84 " " 

85 f"{docx_path} after {retries} attempts." 

86 ) 

87 raise RuntimeError(msg) # pragma: no cover 

88 

89 

90def _create_unique_output_dir(output_root: Path) -> Path: 

91 """Create a uniquely named subdirectory (4-char hex) inside output_root. 

92 

93 Raises: 

94 RuntimeError: If a unique directory cannot be created after 500 attempts. 

95 

96 """ 

97 retries = 500 

98 

99 for _ in range(retries): 99 ↛ 108line 99 didn't jump to line 108 because the loop on line 99 didn't complete

100 candidate = output_root / uuid.uuid4().hex[:4] 

101 try: 

102 candidate.mkdir(parents=True, exist_ok=False) 

103 except FileExistsError: 

104 continue 

105 else: 

106 return candidate 

107 

108 msg = ( 

109 "Cannot generate a unique output subdirectory under:" 

110 " " 

111 f"{output_root} after {retries} attempts." 

112 ) 

113 raise RuntimeError(msg) 

114 

115 

116class _TestCaseEntry(TypedDict): 

117 test_campaign_name: str 

118 test_suite_name: str 

119 test_case_name: str 

120 file_path: Path 

121 

122 

123def _safe_iter_lines(file_path: Path, logger: ILogger) -> Iterator[str]: 

124 try: 

125 with file_path.open("r", encoding="utf-8", errors="replace") as f: 

126 yield from f 

127 except Exception as exc: 

128 msg = f"Cannot read: {file_path}" 

129 logger.exception(msg, exc=exc) 

130 

131 

132def _save_docx(*, doc: DocxDocument, docx_path: Path, logger: ILogger) -> None: 

133 try: 

134 doc.save(str(docx_path)) 

135 logger.success(f"Successfully written: {docx_path}") 

136 except Exception as exc: 

137 msg = f"Cannot save file: {docx_path}" 

138 logger.exception(msg, exc=exc) 

139 

140 

141@final 

142class _DocxProofGenerator: 

143 """Generate DOCX test proofs from log files. 

144 

145 Expected log tree: 

146 logs_root/ 

147 ├── campaign_1/ 

148 │ ├── suite_A/ 

149 │ │ ├── test_case_1.log 

150 │ │ └── test_case_2.log 

151 │ └── suite_B/ 

152 │ └── test_case_3.log 

153 └── campaign_2/ 

154 └── ... 

155 """ 

156 

157 def __init__( 

158 self, 

159 *, 

160 logs_root: Path, 

161 docx_root: Path, 

162 screenshot_needle: str = _DEFAULT_SCREENSHOT_NEEDLE, 

163 utc_date_regex: re.Pattern[str] = _DEFAULT_UTC_DATE_REGEX, 

164 ) -> None: 

165 self.logs_root = logs_root 

166 self.docx_root = docx_root 

167 self._screenshot_needle = screenshot_needle 

168 self._utc_date_regex = utc_date_regex 

169 

170 def _iter_test_cases(self, logger: ILogger) -> Iterator[_TestCaseEntry]: 

171 try: 

172 for campaign_dir in self.logs_root.iterdir(): 

173 if not campaign_dir.is_dir(): 

174 continue 

175 

176 for suite_dir in campaign_dir.iterdir(): 

177 if not suite_dir.is_dir(): 

178 continue 

179 

180 for file in suite_dir.iterdir(): 

181 if file.is_file(): 181 ↛ 180line 181 didn't jump to line 180 because the condition on line 181 was always true

182 yield _TestCaseEntry( 

183 test_campaign_name=campaign_dir.name, 

184 test_suite_name=suite_dir.name, 

185 test_case_name=file.stem, 

186 file_path=file, 

187 ) 

188 except OSError as exc: 

189 msg = f"Failed to iterate over: {self.logs_root}" 

190 logger.exception(msg, exc=exc) 

191 

192 def _create_docx_from_case( 

193 self, test_case: _TestCaseEntry, logger: ILogger 

194 ) -> bool: 

195 campaign_name = test_case["test_campaign_name"] 

196 suite_name = test_case["test_suite_name"] 

197 case_name = test_case["test_case_name"] 

198 file_path = test_case["file_path"] 

199 

200 doc = Document() 

201 doc.add_heading( 

202 campaign_name, level=1 

203 ).alignment = WD_PARAGRAPH_ALIGNMENT.CENTER 

204 doc.add_heading(suite_name, level=2).alignment = WD_PARAGRAPH_ALIGNMENT.CENTER 

205 doc.add_paragraph() 

206 doc.add_heading(case_name, level=3) 

207 

208 for line in _safe_iter_lines(file_path, logger): 

209 normalized_line = _replace_utc_date( 

210 line.rstrip("\n"), utc_date_regex=self._utc_date_regex 

211 ) 

212 

213 if self._screenshot_needle in normalized_line: 

214 parts = normalized_line.split(self._screenshot_needle, 1) 

215 if len(parts) > 1 and parts[1].strip(): 215 ↛ 208line 215 didn't jump to line 208 because the condition on line 215 was always true

216 image_path = Path(parts[1].strip()) 

217 if image_path.exists() and image_path.is_file(): 

218 doc.add_paragraph() 

219 try: 

220 doc.add_picture(str(image_path), width=Inches(6)) 

221 except Exception as exc: 

222 msg = f"Cannot add image {image_path}" 

223 logger.exception(msg, exc=exc) 

224 else: 

225 p = doc.add_paragraph() 

226 p.paragraph_format.space_before = 0 

227 p.paragraph_format.space_after = 0 

228 p.add_run(normalized_line) 

229 

230 relative_path = file_path.relative_to(self.logs_root) 

231 docx_path = (self.docx_root / relative_path).with_suffix(".docx").resolve() 

232 

233 try: 

234 docx_path.parent.mkdir(parents=True, exist_ok=True) 

235 doc.save(str(docx_path)) 

236 except FileNotFoundError, OSError: 

237 original_docx_path = docx_path 

238 docx_path = _shorten_docx_path(docx_path) 

239 msg = ( 

240 f"Filename too long or access error for: {original_docx_path}." 

241 f" Retrying with: {docx_path}" 

242 ) 

243 logger.warning(msg) 

244 _save_docx(docx_path=docx_path, doc=doc, logger=logger) 

245 return True 

246 else: 

247 return True 

248 

249 def generate_docx_proofs(self, logger: ILogger) -> int: 

250 return sum( 

251 self._create_docx_from_case(case, logger) 

252 for case in self._iter_test_cases(logger) 

253 ) 

254 

255 

256def generate_docx_proof( # noqa: PLR0913 

257 *, 

258 logs_root: Path, 

259 output_root: Path, 

260 logger: ILogger, 

261 screenshot_needle: str = _DEFAULT_SCREENSHOT_NEEDLE, 

262 utc_date_regex: re.Pattern[str] = _DEFAULT_UTC_DATE_REGEX, 

263 auto_create_unique_directory: bool = True, 

264) -> None: 

265 """Generate DOCX test proofs from log files. 

266 

267 Args: 

268 logs_root: Root directory of the log tree to process. 

269 output_root: Root directory where DOCX files will be written. 

270 logger: Logger for progress and error reporting. 

271 screenshot_needle: used to detect screenshot lines. Default: "Screenshot: ". 

272 utc_date_regex: used to detect and replace UTC dates. Default: [UTC_DATE::...]. 

273 auto_create_unique_directory: creates automatically a random-named unique dir. 

274 

275 """ 

276 if not logs_root.is_dir(): 

277 msg = f"Directory not accessible: {logs_root}. Generation skipped." 

278 logger.warning(msg) 

279 return 

280 

281 docx_root = ( 

282 _create_unique_output_dir(output_root) 

283 if auto_create_unique_directory 

284 else output_root 

285 ) 

286 

287 generated_count = _DocxProofGenerator( 

288 logs_root=logs_root, 

289 docx_root=docx_root, 

290 screenshot_needle=screenshot_needle, 

291 utc_date_regex=utc_date_regex, 

292 ).generate_docx_proofs(logger) 

293 

294 if generated_count == 0: 

295 msg = f"No test case found under: {logs_root}. Nothing was generated." 

296 logger.warning(msg) 

297 if auto_create_unique_directory: 297 ↛ 300line 297 didn't jump to line 300 because the condition on line 297 was always true

298 with suppress(Exception): 

299 docx_root.rmdir() 

300 return 

301 

302 msg = ( 

303 f"Plugin execution done. Generated {generated_count} DOCX. Output: {docx_root}" 

304 ) 

305 logger.info(msg)