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-31 10:31 +0200
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-31 10:31 +0200
1"""Generate test proof documents in DOCX format.
3Transforms text log files from automated tests into formatted Word documents,
4including screenshots.
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
12Output format:
13 - Heading 1: Test campaign
14 - Heading 2: Test suite
15 - Heading 3: Test case
16 - Body: Logs with inserted screenshots
18Example:
19 >>> generate_docx_proof(
20 ... logs_root=Path("logs/e2e"),
21 ... docx_root=Path(".docx_test_proofs_xxx"),
22 ... logger=logger,
23 ... )
25"""
27import re
28import uuid
29from contextlib import suppress
30from datetime import datetime
31from pathlib import Path
32from typing import TYPE_CHECKING, TypedDict, final
34from docx import Document
35from docx.enum.text import WD_PARAGRAPH_ALIGNMENT
36from docx.shared import Inches
38if TYPE_CHECKING:
39 from collections.abc import Iterator
41 from docx.document import Document as DocxDocument
43 from ocarina.ports.ilogger import ILogger
45_DEFAULT_SCREENSHOT_NEEDLE = "Screenshot: "
46_DEFAULT_UTC_DATE_REGEX = re.compile(r"\[UTC_DATE::([^]]+)]")
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)
59 return utc_date_regex.sub(_repl, line)
62def _shorten_docx_path(docx_path: Path) -> Path:
63 """Return a short unique path if stem exceeds 8 chars, otherwise unchanged.
65 Raises:
66 RuntimeError: If a unique filename cannot be generated after 500 attempts.
68 """
69 max_stem_length = 8
70 retries = 500
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
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
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
90def _create_unique_output_dir(output_root: Path) -> Path:
91 """Create a uniquely named subdirectory (4-char hex) inside output_root.
93 Raises:
94 RuntimeError: If a unique directory cannot be created after 500 attempts.
96 """
97 retries = 500
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
108 msg = (
109 "Cannot generate a unique output subdirectory under:"
110 " "
111 f"{output_root} after {retries} attempts."
112 )
113 raise RuntimeError(msg)
116class _TestCaseEntry(TypedDict):
117 test_campaign_name: str
118 test_suite_name: str
119 test_case_name: str
120 file_path: Path
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)
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)
141@final
142class _DocxProofGenerator:
143 """Generate DOCX test proofs from log files.
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 """
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
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
176 for suite_dir in campaign_dir.iterdir():
177 if not suite_dir.is_dir():
178 continue
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)
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"]
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)
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 )
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)
230 relative_path = file_path.relative_to(self.logs_root)
231 docx_path = (self.docx_root / relative_path).with_suffix(".docx").resolve()
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
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 )
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.
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.
275 """
276 if not logs_root.is_dir():
277 msg = f"Directory not accessible: {logs_root}. Generation skipped."
278 logger.warning(msg)
279 return
281 docx_root = (
282 _create_unique_output_dir(output_root)
283 if auto_create_unique_directory
284 else output_root
285 )
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)
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
302 msg = (
303 f"Plugin execution done. Generated {generated_count} DOCX. Output: {docx_root}"
304 )
305 logger.info(msg)